diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..856536052b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +# Tabs in JS unless otherwise specified +[**.js] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000000..85407969a3 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,76 @@ +{ + "env": { + "browser": true, + "es6": true + }, + "rules": { + "comma-dangle": 2, + "no-cond-assign": 2, + "no-control-regex": 2, + "no-debugger": 2, + "no-dupe-args": 2, + "no-dupe-keys": 2, + "no-duplicate-case": 2, + "no-empty-character-class": 2, + "no-empty": 2, + "no-ex-assign": 2, + "no-extra-boolean-cast": 2, + "no-extra-parens": 2, + "no-extra-semi": 2, + "no-func-assign": 2, + "no-inner-declarations": 2, + "no-invalid-regexp": 2, + "no-negated-in-lhs": 2, + "no-regex-spaces": 2, + "no-sparse-arrays": 2, + "no-unexpected-multiline": 2, + "no-unreachable": 2, + "valid-typeof": 2, + "accessor-pairs": 2, + "block-scoped-var": 2, + "complexity": 2, + "curly": 2, + "dot-notation": [2, {"allowKeywords": false}], + "eqeqeq": 2, + "guard-for-in": 2, + "semi": 2, + "no-alert": 2, + "no-caller": 2, + "no-case-declarations": 2, + "no-div-regex": 2, + "no-else-return": 2, + "no-empty-label": 2, + "no-empty-pattern": 2, + "no-eq-null": 2, + "no-eval": 2, + "no-extend-native": 2, + "no-extra-bind": 2, + "no-floating-decimal": 2, + "no-implied-eval": 2, + "no-invalid-this": 2, + "no-iterator": 2, + "no-lone-blocks": 2, + "no-loop-func": 2, + "no-multi-spaces": 2, + "no-native-reassign": 2, + "no-new-func": 2, + "no-new-wrappers": 2, + "no-new": 2, + "no-octal-escape": 2, + "no-octal": 2, + "no-proto": 2, + "no-redeclare": 2, + "no-script-url": 2, + "no-self-compare": 2, + "no-sequences": 2, + "no-throw-literal": 2, + "no-unused-expressions": 2, + "no-useless-call": 2, + "no-useless-concat": 2, + "no-void": 2, + "no-with": 2, + "radix": 2, + "wrap-iife": 2, + "yoda": 2 + } +} diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000..6b74877384 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,5 @@ +# PLEASE READ + +As per the [README](https://github.com/angular-ui/bootstrap/blob/master/README.md), this project is no longer being maintained. Any issues entered will remain uninvestigated and unresolved. + +We thank you for your contributions over the years. This library would not have been successful without them. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..676cdb40f9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,5 @@ +# PLEASE READ + +As per the [README](https://github.com/angular-ui/bootstrap/blob/master/README.md), this project is no longer being maintained. Any PRs entered will not be reviewed or merged and will remain open. + +We thank you for your contributions over the years. This library would not have been successful without them. diff --git a/.gitignore b/.gitignore index 45aee72754..6716155984 100644 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,14 @@ lib-cov *.swp *.swo .DS_Store +.idea pids logs results dist +# test coverage files +.coverage/ node_modules npm-debug.log diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000000..b36c317d67 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,30 @@ +{ + "curly": true, + "immed": true, + "newcap": true, + "noarg": true, + "sub": true, + "boss": true, + "eqnull": true, + "quotmark": "single", + "trailing": true, + "undef": true, + "browser": true, + "jquery": true, + "globals": { + "angular": false, + + // For Jasmine + "after" : false, + "afterEach" : false, + "before" : false, + "beforeEach" : false, + "describe" : false, + "expect" : false, + "jasmine" : false, + "module" : false, + "spyOn" : false, + "inject" : false, + "it" : false + } +} \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000000..925a2cd919 --- /dev/null +++ b/.npmignore @@ -0,0 +1,38 @@ +lib-cov +*.seed +*.log +*.csv +*.dat +*.out +*.pid +*.gz +*.swp +*.swo +.DS_Store + +pids +logs +results +# test coverage files +.coverage/ + +node_modules +npm-debug.log + +.git +docs +misc +.editorconfig +.gitattributes +.gitignore +.jshintrc +.travis.yml +CONTRIBUTING.md +Gruntfile.js +karma.conf.js +ROADMAP.md + +dist/assets +dist/index.html +dist/versions-mapping.json +dist/*-SNAPSHOT* diff --git a/.travis.yml b/.travis.yml index bcad7a389a..e69e4c76f0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,20 @@ - language: node_js - node_js: - - "0.8" +language: node_js +node_js: + - "5.9" +env: + - CXX=g++-4.8 +dist: trusty +addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - g++-4.8 - before_script: +before_install: - export DISPLAY=:99.0 - sh -e /etc/init.d/xvfb start - - npm install --quiet -g grunt-cli karma - - npm install + - npm install --quiet -g karma - script: grunt \ No newline at end of file +script: grunt +sudo: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 64c18aff37..56e5e8e6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,57 +1,1852 @@ -# 0.5.0 (2013-08-04) + +# [2.5.0](https://github.com/angular-ui/bootstrap/compare/2.4.0...v2.5.0) (2017-01-28) + + +### Bug Fixes + +* **angular:** add compatibility with Angular 1.6([0d79005](https://github.com/angular-ui/bootstrap/commit/0d79005)), closes [#6427](https://github.com/angular-ui/bootstrap/issues/6427) [#6360](https://github.com/angular-ui/bootstrap/issues/6360) +* **carousel:** remove transition buffering([86ee770](https://github.com/angular-ui/bootstrap/commit/86ee770)), closes [#6367](https://github.com/angular-ui/bootstrap/issues/6367) [#5967](https://github.com/angular-ui/bootstrap/issues/5967) +* **dropdown:** do nothing if not open when clicking([761db7b](https://github.com/angular-ui/bootstrap/commit/761db7b)), closes [#6414](https://github.com/angular-ui/bootstrap/issues/6414) +* **tooltip:** unbind keypress listener on hide([f5b357f](https://github.com/angular-ui/bootstrap/commit/f5b357f)), closes [#6423](https://github.com/angular-ui/bootstrap/issues/6423) [#6405](https://github.com/angular-ui/bootstrap/issues/6405) + + +### Features + +* **dropdown:** make dropdown-append-to-body configurable ([#6356](https://github.com/angular-ui/bootstrap/issues/6356))([7d3a750](https://github.com/angular-ui/bootstrap/commit/7d3a750)) +* **pagination:** Added menu and menuitem roles (closes [#6383](https://github.com/angular-ui/bootstrap/issues/6383)) ([#6386](https://github.com/angular-ui/bootstrap/issues/6386))([71dc691](https://github.com/angular-ui/bootstrap/commit/71dc691)), closes [#6383](https://github.com/angular-ui/bootstrap/issues/6383) [(#6386](https://github.com/(/issues/6386) + + + + +# [2.4.0](https://github.com/angular-ui/bootstrap/compare/2.3.2...v2.4.0) (2016-12-30) + + +### Features + +* **dateparser:** allow overriding of parsers([5a3e44a](https://github.com/angular-ui/bootstrap/commit/5a3e44a)), closes [#6370](https://github.com/angular-ui/bootstrap/issues/6370) [#6373](https://github.com/angular-ui/bootstrap/issues/6373) + + + + +## [2.3.2](https://github.com/angular-ui/bootstrap/compare/2.3.1...v2.3.2) (2016-12-27) + + +### Bug Fixes + +* **dropdown:** re-add close([955848c](https://github.com/angular-ui/bootstrap/commit/955848c)), closes [#6382](https://github.com/angular-ui/bootstrap/issues/6382) [#6321](https://github.com/angular-ui/bootstrap/issues/6321) [#6357](https://github.com/angular-ui/bootstrap/issues/6357) [#6364](https://github.com/angular-ui/bootstrap/issues/6364) + + + + +## [2.3.1](https://github.com/angular-ui/bootstrap/compare/2.3.0...v2.3.1) (2016-12-10) + + +### Bug Fixes + +* **dateparser:** add new date format for angular 1.5+ only([f2722b5](https://github.com/angular-ui/bootstrap/commit/f2722b5)), closes [#6349](https://github.com/angular-ui/bootstrap/issues/6349) + +* **datepickerPopup:** change to toTimezone only([1962485](https://github.com/angular-ui/bootstrap/commit/1962485)), fixes [#6235](https://github.com/angular-ui/bootstrap/issues/6235) + +* **modal:** revert focus behavior on open([8a4f625](https://github.com/angular-ui/bootstrap/commit/8a4f625)), closes [#6295](https://github.com/angular-ui/bootstrap/issues/6295) + + +# [2.3.0](https://github.com/angular-ui/bootstrap/compare/2.2.0...2.3.0) (2016-11-26) + + +### Features + +* **dateparser:** add LLLL support([25ff206](https://github.com/angular-ui/bootstrap/commit/25ff206)), closes [#6281](https://github.com/angular-ui/bootstrap/issues/6281) + + + + +# [2.2.0](https://github.com/angular-ui/bootstrap/compare/2.1.4...2.2.0) (2016-10-10) + + +### Bug Fixes + +* **dropdown:** exit keybind is not open ([14384fc](https://github.com/angular-ui/bootstrap/commit/14384fc)), closes [#6278](https://github.com/angular-ui/bootstrap/issues/6278) [#6208](https://github.com/angular-ui/bootstrap/issues/6208) +* **modal:** improve ARIA support. ([f9f7e02](https://github.com/angular-ui/bootstrap/commit/f9f7e02)), closes [#6203](https://github.com/angular-ui/bootstrap/issues/6203) +* **position:** correct scrollbar width calculation ([58f1813](https://github.com/angular-ui/bootstrap/commit/58f1813)), closes [#6273](https://github.com/angular-ui/bootstrap/issues/6273) +* **tooltip:** cancel timeout when hidden ([7855976](https://github.com/angular-ui/bootstrap/commit/7855976)), closes [#6226](https://github.com/angular-ui/bootstrap/issues/6226) [#6221](https://github.com/angular-ui/bootstrap/issues/6221) + +### Features + +* **timepicker:** add validation information ([9666c64](https://github.com/angular-ui/bootstrap/commit/9666c64)), closes [#6230](https://github.com/angular-ui/bootstrap/issues/6230) [#6259](https://github.com/angular-ui/bootstrap/issues/6259) + + + + +## [2.1.4](https://github.com/angular-ui/bootstrap/compare/2.1.3...2.1.4) (2016-09-24) + + +### Bug Fixes + +* **datepicker:** improve accessibility ([3f70d76](https://github.com/angular-ui/bootstrap/commit/3f70d76)), closes [#6247](https://github.com/angular-ui/bootstrap/issues/6247) +* **dropdown:** prevent premature scope removal ([08ee30a](https://github.com/angular-ui/bootstrap/commit/08ee30a)), closes [#6238](https://github.com/angular-ui/bootstrap/issues/6238) [#6225](https://github.com/angular-ui/bootstrap/issues/6225) + + + + +## [2.1.3](https://github.com/angular-ui/bootstrap/compare/2.1.2...2.1.3) (2016-08-25) + + +### Bug Fixes + +* **modal:** compile only once with component ([969eb9c](https://github.com/angular-ui/bootstrap/commit/969eb9c)), closes [#6202](https://github.com/angular-ui/bootstrap/issues/6202) [#6201](https://github.com/angular-ui/bootstrap/issues/6201) + + + + +## [2.1.2](https://github.com/angular-ui/bootstrap/compare/2.1.1...2.1.2) (2016-08-22) + + +### Bug Fixes + +* **collapse:** revert change to transition css ([515bcf2](https://github.com/angular-ui/bootstrap/commit/515bcf2)), closes [#6196](https://github.com/angular-ui/bootstrap/issues/6196) [#6194](https://github.com/angular-ui/bootstrap/issues/6194) +* **datepicker:** fix accidental global ([ddcacb7](https://github.com/angular-ui/bootstrap/commit/ddcacb7)), closes [#6188](https://github.com/angular-ui/bootstrap/issues/6188) +* **modal:** close and dismiss bindings on component ([3e8ecff](https://github.com/angular-ui/bootstrap/commit/3e8ecff)), closes [#6192](https://github.com/angular-ui/bootstrap/issues/6192) [#6191](https://github.com/angular-ui/bootstrap/issues/6191) + + + + +## [2.1.1](https://github.com/angular-ui/bootstrap/compare/2.1.0...2.1.1) (2016-08-21) + + +### Bug Fixes + +* **collapse:** default to css([aef24cd](https://github.com/angular-ui/bootstrap/commit/aef24cd)), closes [#6182](https://github.com/angular-ui/bootstrap/issues/6182) [#6045](https://github.com/angular-ui/bootstrap/issues/6045) +* **modal:** switch to .append([fb5fabf](https://github.com/angular-ui/bootstrap/commit/fb5fabf)), closes [#6187](https://github.com/angular-ui/bootstrap/issues/6187) [#6186](https://github.com/angular-ui/bootstrap/issues/6186) + + + + +# [2.1.0](https://github.com/angular-ui/bootstrap/compare/2.0.2...2.1.0) (2016-08-19) + + +### Bug Fixes + +* **collapse:** remove unnecessary inherit ([ca20be4](https://github.com/angular-ui/bootstrap/commit/ca20be4)), closes [#6164](https://github.com/angular-ui/bootstrap/issues/6164) [#6163](https://github.com/angular-ui/bootstrap/issues/6163) +* **collapse:** set overflow to hidden on transition ([84cc2cf](https://github.com/angular-ui/bootstrap/commit/84cc2cf)), closes [#6180](https://github.com/angular-ui/bootstrap/issues/6180) [#5474](https://github.com/angular-ui/bootstrap/issues/5474) +* **datepickerPopup:** apply timezone conversion ([f147d22](https://github.com/angular-ui/bootstrap/commit/f147d22)), closes [#6173](https://github.com/angular-ui/bootstrap/issues/6173) [#6147](https://github.com/angular-ui/bootstrap/issues/6147) +* **modal:** improve ARIA support ([4a5e6a7](https://github.com/angular-ui/bootstrap/commit/4a5e6a7)), closes [#4772](https://github.com/angular-ui/bootstrap/issues/4772) +* **tooltip:** close tooltip on esc ([f5ff12c](https://github.com/angular-ui/bootstrap/commit/f5ff12c)), closes [#6177](https://github.com/angular-ui/bootstrap/issues/6177) [#6108](https://github.com/angular-ui/bootstrap/issues/6108) + +### Features + +* **modal:** add component support ([2ade054](https://github.com/angular-ui/bootstrap/commit/2ade054)), closes [#5683](https://github.com/angular-ui/bootstrap/issues/5683) [#6179](https://github.com/angular-ui/bootstrap/issues/6179) + + + + +## [2.0.2](https://github.com/angular-ui/bootstrap/compare/2.0.1...2.0.2) (2016-08-15) + + +### Bug Fixes + +* **datepickerPopup:** correctly format to timezone ([fbd0845](https://github.com/angular-ui/bootstrap/commit/fbd0845)), closes [#6159](https://github.com/angular-ui/bootstrap/issues/6159) [#6105](https://github.com/angular-ui/bootstrap/issues/6105) [#6146](https://github.com/angular-ui/bootstrap/issues/6146) [#6147](https://github.com/angular-ui/bootstrap/issues/6147) +* **dropdown:** fix keyboard-nav ([6bad759](https://github.com/angular-ui/bootstrap/commit/6bad759)), closes [#6102](https://github.com/angular-ui/bootstrap/issues/6102) [#6154](https://github.com/angular-ui/bootstrap/issues/6154) + + + + +## [2.0.1](https://github.com/angular-ui/bootstrap/compare/2.0.0...2.0.1) (2016-08-02) + + +### Bug Fixes + +* **modal:** restore broken stacked modals([c61d16a](https://github.com/angular-ui/bootstrap/commit/c61d16a)), closes [#6103](https://github.com/angular-ui/bootstrap/issues/6103) [#6104](https://github.com/angular-ui/bootstrap/issues/6104) + + + + +# [2.0.0](https://github.com/angular-ui/bootstrap/compare/1.3.3...2.0.0) (2016-07-20) + + +### Bug Fixes + +* **dateparser:** correctly format with literals([d846e2d](https://github.com/angular-ui/bootstrap/commit/d846e2d)), closes [#6055](https://github.com/angular-ui/bootstrap/issues/6055) [#5620](https://github.com/angular-ui/bootstrap/issues/5620) [#5802](https://github.com/angular-ui/bootstrap/issues/5802) +* **datepickerPopup:** clear date when button is clicked([b0466d9](https://github.com/angular-ui/bootstrap/commit/b0466d9)), closes [#5945](https://github.com/angular-ui/bootstrap/issues/5945) [#5906](https://github.com/angular-ui/bootstrap/issues/5906) +* **datepickerPopup:** specify dependency on datepicker([4fef037](https://github.com/angular-ui/bootstrap/commit/4fef037)), closes [#5954](https://github.com/angular-ui/bootstrap/issues/5954) +* **datepickerPopup:** use value instead of viewValue([7e320e0](https://github.com/angular-ui/bootstrap/commit/7e320e0)), closes [#6007](https://github.com/angular-ui/bootstrap/issues/6007) +* **dropdown:** align position correctly with scrollbar([2d831bc](https://github.com/angular-ui/bootstrap/commit/2d831bc)), closes [#6008](https://github.com/angular-ui/bootstrap/issues/6008) [#5942](https://github.com/angular-ui/bootstrap/issues/5942) +* **dropdown:** bind event listener on dropdown menu([6038403](https://github.com/angular-ui/bootstrap/commit/6038403)), closes [#5982](https://github.com/angular-ui/bootstrap/issues/5982) +* **modal:** check for overflow hidden([433e536](https://github.com/angular-ui/bootstrap/commit/433e536)), closes [#6037](https://github.com/angular-ui/bootstrap/issues/6037) [#6041](https://github.com/angular-ui/bootstrap/issues/6041) +* **modal:** filter out non-tabbable elements([35ced04](https://github.com/angular-ui/bootstrap/commit/35ced04)), closes [#5963](https://github.com/angular-ui/bootstrap/issues/5963) [#5955](https://github.com/angular-ui/bootstrap/issues/5955) +* **modal:** remove unused template from modal([1de58a3](https://github.com/angular-ui/bootstrap/commit/1de58a3)) +* **modal:** remove window class after animation([409b7aa](https://github.com/angular-ui/bootstrap/commit/409b7aa)), closes [#6056](https://github.com/angular-ui/bootstrap/issues/6056) [#6051](https://github.com/angular-ui/bootstrap/issues/6051) +* **tooltip:** missed dependency for cjs([164811a](https://github.com/angular-ui/bootstrap/commit/164811a)), closes [#5999](https://github.com/angular-ui/bootstrap/issues/5999) +* **tooltip:** reposition for placement top([34a1443](https://github.com/angular-ui/bootstrap/commit/34a1443)), closes [#5959](https://github.com/angular-ui/bootstrap/issues/5959) +* **typeahead:** change to select class([13c14af](https://github.com/angular-ui/bootstrap/commit/13c14af)), closes [#5922](https://github.com/angular-ui/bootstrap/issues/5922) [#5848](https://github.com/angular-ui/bootstrap/issues/5848) +* **typeahead:** clear validity in $digest([ed3400b](https://github.com/angular-ui/bootstrap/commit/ed3400b)), closes [#6033](https://github.com/angular-ui/bootstrap/issues/6033) [#6032](https://github.com/angular-ui/bootstrap/issues/6032) +* **typeahead:** remove duplicate id attribute([6d5b84a](https://github.com/angular-ui/bootstrap/commit/6d5b84a)), closes [#5936](https://github.com/angular-ui/bootstrap/issues/5936) [#5926](https://github.com/angular-ui/bootstrap/issues/5926) + + +### Features + +* **accordion:** add appropriate tabindex on disabled([5f4eedd](https://github.com/angular-ui/bootstrap/commit/5f4eedd)), closes [#4067](https://github.com/angular-ui/bootstrap/issues/4067) [#5990](https://github.com/angular-ui/bootstrap/issues/5990) +* **accordion:** remove replace: true usage([3819bbe](https://github.com/angular-ui/bootstrap/commit/3819bbe)), closes [#5985](https://github.com/angular-ui/bootstrap/issues/5985) +* **alert:** remove replace: true usage([2458c28](https://github.com/angular-ui/bootstrap/commit/2458c28)), closes [#5986](https://github.com/angular-ui/bootstrap/issues/5986) +* **carousel:** remove replace: true usage([5046bd4](https://github.com/angular-ui/bootstrap/commit/5046bd4)), closes [#5987](https://github.com/angular-ui/bootstrap/issues/5987) +* **collapse:** add horizontal support([1ec0997](https://github.com/angular-ui/bootstrap/commit/1ec0997)), closes [#3593](https://github.com/angular-ui/bootstrap/issues/3593) [#6010](https://github.com/angular-ui/bootstrap/issues/6010) +* **datepicker:** add monthColumns([39d9b98](https://github.com/angular-ui/bootstrap/commit/39d9b98)), closes [#5515](https://github.com/angular-ui/bootstrap/issues/5515) [#5935](https://github.com/angular-ui/bootstrap/issues/5935) +* **datepicker:** add ngModelOptions support([23b91d9](https://github.com/angular-ui/bootstrap/commit/23b91d9)), closes [#5933](https://github.com/angular-ui/bootstrap/issues/5933) [#5825](https://github.com/angular-ui/bootstrap/issues/5825) +* **datepicker:** remove replace: true usage([e92fb7f](https://github.com/angular-ui/bootstrap/commit/e92fb7f)), closes [#5988](https://github.com/angular-ui/bootstrap/issues/5988) +* **datepickerPopup:** remove replace usage([a47bced](https://github.com/angular-ui/bootstrap/commit/a47bced)), closes [#5993](https://github.com/angular-ui/bootstrap/issues/5993) +* **dropdown:** focus toggle on close from click([cce0097](https://github.com/angular-ui/bootstrap/commit/cce0097)), closes [#5934](https://github.com/angular-ui/bootstrap/issues/5934) [#5782](https://github.com/angular-ui/bootstrap/issues/5782) +* **dropdown:** use .value() for uibDropdownConfig([7457fb0](https://github.com/angular-ui/bootstrap/commit/7457fb0)), closes [#6004](https://github.com/angular-ui/bootstrap/issues/6004) [#6006](https://github.com/angular-ui/bootstrap/issues/6006) +* **modal:** append using $animate([1cbd73d](https://github.com/angular-ui/bootstrap/commit/1cbd73d)), closes [#6023](https://github.com/angular-ui/bootstrap/issues/6023) [#6029](https://github.com/angular-ui/bootstrap/issues/6029) +* **modal:** remove replace usage([96d52ce](https://github.com/angular-ui/bootstrap/commit/96d52ce)), closes [#5989](https://github.com/angular-ui/bootstrap/issues/5989) +* **pager:** remove replace usage([9b24e1d](https://github.com/angular-ui/bootstrap/commit/9b24e1d)), closes [#5991](https://github.com/angular-ui/bootstrap/issues/5991) +* **pager:** toggle tabindex when disabled([0d8cec6](https://github.com/angular-ui/bootstrap/commit/0d8cec6)), closes [#5996](https://github.com/angular-ui/bootstrap/issues/5996) +* **pagination:** disable tabbing when disabled([1a870a3](https://github.com/angular-ui/bootstrap/commit/1a870a3)), closes [#5984](https://github.com/angular-ui/bootstrap/issues/5984) +* **pagination:** remove replace usage([387c6e7](https://github.com/angular-ui/bootstrap/commit/387c6e7)), closes [#5992](https://github.com/angular-ui/bootstrap/issues/5992) +* **rating:** remove replace usage([d6fe9c7](https://github.com/angular-ui/bootstrap/commit/d6fe9c7)), closes [#5998](https://github.com/angular-ui/bootstrap/issues/5998) +* **stackedMap:** improve perf of removeTop([a075824](https://github.com/angular-ui/bootstrap/commit/a075824)), closes [#5925](https://github.com/angular-ui/bootstrap/issues/5925) [#5932](https://github.com/angular-ui/bootstrap/issues/5932) +* **timepicker:** avoid allowing to tab to controls([4e68778](https://github.com/angular-ui/bootstrap/commit/4e68778)), closes [#5930](https://github.com/angular-ui/bootstrap/issues/5930) +* **timepicker:** remove replace usage([7518821](https://github.com/angular-ui/bootstrap/commit/7518821)), closes [#5997](https://github.com/angular-ui/bootstrap/issues/5997) +* **tooltip:** add expression support([4b91222](https://github.com/angular-ui/bootstrap/commit/4b91222)), closes [#5221](https://github.com/angular-ui/bootstrap/issues/5221) [#5938](https://github.com/angular-ui/bootstrap/issues/5938) +* **tooltip:** remove replace usage([1616e97](https://github.com/angular-ui/bootstrap/commit/1616e97)), closes [#5994](https://github.com/angular-ui/bootstrap/issues/5994) + + +### Reverts + +* **dropdown:** change back to .constant()([4e0e34f](https://github.com/angular-ui/bootstrap/commit/4e0e34f)) + + +### BREAKING CHANGES + +* tooltip: The template structure changed slightly due to the removal of `replace: true` - see documentation examples in action to see differences in practice. +* typeahead: This changes the selector used so that it doesn't select for the `li` tag specifically, but the elements with the class `uib-typeahead-match` - if using a custom template, this class needs to be added to the `li` element in the typeahead popup template. +* rating: Due to the removal of `replace: true`, this results in a slight change to the HTML structure - see the documentation examples to see it in action. +* timepicker: This removes `replace: true`, which changes the HTML structure slightly - see the documentation examples to see it in action. +* datepickerPopup: Due to the nature of `replace: true`, this has a slight structural HTML change in the popup as a result - see documentation examples for the change in action. +* pagination: Due to the removal of `replace: true`, this affects the HTML structure slightly - see documentation examples to see new usage. +* pager: This removes `replace: true` usage from the pager, which causes a slight usage change - see documentation examples for new usage. +* modal: This removes `replace: true` usage, causing some structural changes to the HTML - the major part here is that there is no more backdrop template, and the top level elements for the window/backdrop elements lose a little flexibility. See documentation examples for new structure. +* modal: This introduces a minor behavior change in when the class is removed +* carousel: Due to the removal of `replace: true`, this causes a slight HTML structure change to the carousel and the slide elements - see documentation demos to see how it changes. This also caused removal of the ngTouch built in support - if one is using ng-touch, one needs to add the `ng-swipe-left` and `ng-swipe-right` directives to the carousel element with relevant logic. +* alert: This removes the `replace: true` usage, so this has an effect on how one uses the directive in the template - see documentation for reference +* accordion: This removes usage of `replace: true` in the accordion group, which results in a template change where the template no longer needs to contain the panel itself, but its contents. The accordion group will add the `panel` class by default, so the user just needs to add the appropriate classes to the accordion group element. This allows the user to use ng-class as well to fully control the panel related classes, so `panel-class` now is unnecessary +* tooltip: This removes support for plain strings and interpolations in tooltip-trigger and popover-trigger - please change these appropriately. See test changes in this commit for reference +* typeahead: This change removes the `id` attribute on the first `` +element placed into the DOM when the `typeahead-show-hint` attribute is used +and there is an `id` attribute present on the original `uib-typeahead` element. +This could affect selectors if they are being used. +* dropdown: This changes the focus behavior of the dropdown slightly, and potentially may break code built around current usage +* datepicker: This modifies the current behavior around the datepicker & popup's ngModelOptions, which may affect custom components that are built around both +* datepicker: As a result of removal of `replace: true`, there is the potential that this may break some CSS layout due to the slightly different HTML. Refer to the documentation examples to see the new structure. + + + + +## [1.3.3](https://github.com/angular-ui/bootstrap/compare/1.3.2...1.3.3) (2016-05-23) + + +### Bug Fixes + +* **datepicker:** add intermediary valid date check([a417ec2](https://github.com/angular-ui/bootstrap/commit/a417ec2)), closes [#5872](https://github.com/angular-ui/bootstrap/issues/5872) [#5865](https://github.com/angular-ui/bootstrap/issues/5865) +* **datepickerPopup:** convert numbers to date before parsing([5188463](https://github.com/angular-ui/bootstrap/commit/5188463)), closes [#5873](https://github.com/angular-ui/bootstrap/issues/5873) [#5871](https://github.com/angular-ui/bootstrap/issues/5871) +* **dropdown:** align position with vertical scrollbar([2b48259](https://github.com/angular-ui/bootstrap/commit/2b48259)), closes [#5830](https://github.com/angular-ui/bootstrap/issues/5830) [#4317](https://github.com/angular-ui/bootstrap/issues/4317) + + +### Features + +* **accordion:** add alternative attribute support([4c40d9d](https://github.com/angular-ui/bootstrap/commit/4c40d9d)), closes [#5834](https://github.com/angular-ui/bootstrap/issues/5834) [#5839](https://github.com/angular-ui/bootstrap/issues/5839) +* **modal:** add resolve values to template([dd09148](https://github.com/angular-ui/bootstrap/commit/dd09148)), closes [#5808](https://github.com/angular-ui/bootstrap/issues/5808) [#5857](https://github.com/angular-ui/bootstrap/issues/5857) +* **tab:** allow strings for index([26d3995](https://github.com/angular-ui/bootstrap/commit/26d3995)), closes [#5713](https://github.com/angular-ui/bootstrap/issues/5713) [#5827](https://github.com/angular-ui/bootstrap/issues/5827) +* **tabs:** pass the selected index to onDeselect([241fea8](https://github.com/angular-ui/bootstrap/commit/241fea8)), closes [#5820](https://github.com/angular-ui/bootstrap/issues/5820) [#5821](https://github.com/angular-ui/bootstrap/issues/5821) +* **typeahead:** Add support for `should-select`([2ffb86f](https://github.com/angular-ui/bootstrap/commit/2ffb86f)), closes [#5671](https://github.com/angular-ui/bootstrap/issues/5671) [#5794](https://github.com/angular-ui/bootstrap/issues/5794) + + +### BREAKING CHANGES + +* modal: Since this adds support for $resolve being exposed on $scope, it could potentially overwrite any pre-existing usage of it - this is an unlikely scenario, but marked as a breaking change in case this key is being used + + + + +## [1.3.2](https://github.com/angular-ui/bootstrap/compare/1.3.1...1.3.2) (2016-04-14) + + +### Bug Fixes + +* **datepicker:** ensure datepicker is focused ([33cbd0f](https://github.com/angular-ui/bootstrap/commit/33cbd0f)), closes [#5761](https://github.com/angular-ui/bootstrap/issues/5761) [#5754](https://github.com/angular-ui/bootstrap/issues/5754) +* **datepickerPopup:** do not show incorrect warning ([98eb8f1](https://github.com/angular-ui/bootstrap/commit/98eb8f1)), closes [#5743](https://github.com/angular-ui/bootstrap/issues/5743) [#5747](https://github.com/angular-ui/bootstrap/issues/5747) +* **dropdown:** stop esc keydown event ([68200bb](https://github.com/angular-ui/bootstrap/commit/68200bb)), closes [#5778](https://github.com/angular-ui/bootstrap/issues/5778) [#5787](https://github.com/angular-ui/bootstrap/issues/5787) +* **modal:** missing require in modal ([a887d2b](https://github.com/angular-ui/bootstrap/commit/a887d2b)), closes [#5786](https://github.com/angular-ui/bootstrap/issues/5786) +* **modal:** scroll padding only added once ([aef08d5](https://github.com/angular-ui/bootstrap/commit/aef08d5)), closes [#5790](https://github.com/angular-ui/bootstrap/issues/5790) [#5789](https://github.com/angular-ui/bootstrap/issues/5789) +* **site:** allow FastClick to be blocked ([37f9cd2](https://github.com/angular-ui/bootstrap/commit/37f9cd2)), closes [#5756](https://github.com/angular-ui/bootstrap/issues/5756) +* **tooltip:** arrow position ([d96d53e](https://github.com/angular-ui/bootstrap/commit/d96d53e)), closes [#5785](https://github.com/angular-ui/bootstrap/issues/5785) [#5779](https://github.com/angular-ui/bootstrap/issues/5779) +* **typeahead:** use $setViewValue on blur ([bba3f27](https://github.com/angular-ui/bootstrap/commit/bba3f27)), closes [#5769](https://github.com/angular-ui/bootstrap/issues/5769) [#5694](https://github.com/angular-ui/bootstrap/issues/5694) + +### Features + +* **modal:** add no css import script ([a27a4e2](https://github.com/angular-ui/bootstrap/commit/a27a4e2)), closes [#5788](https://github.com/angular-ui/bootstrap/issues/5788) + + +### BREAKING CHANGES + +* dropdown: Stops propagation of keydown event when escape key is pressed. Removes keydown event from the document and moves it to the dropdown element. + + + + +## [1.3.1](https://github.com/angular-ui/bootstrap/compare/1.3.0...1.3.1) (2016-04-05) + + +### Bug Fixes + +* **datepickerPopup:** register popup module ([86ee9c3](https://github.com/angular-ui/bootstrap/commit/86ee9c3)), closes [#5745](https://github.com/angular-ui/bootstrap/issues/5745) +* **modal:** ensure correct index is set ([a08ad70](https://github.com/angular-ui/bootstrap/commit/a08ad70)), closes [#5733](https://github.com/angular-ui/bootstrap/issues/5733) [#5670](https://github.com/angular-ui/bootstrap/issues/5670) + + + + +# [1.3.0](https://github.com/angular-ui/bootstrap/compare/1.2.5...1.3.0) (2016-04-05) + + +### Bug Fixes + +* **carousel:** fix animation from programmatic trigger ([44fb19b](https://github.com/angular-ui/bootstrap/commit/44fb19b)), closes [#5674](https://github.com/angular-ui/bootstrap/issues/5674) [#5601](https://github.com/angular-ui/bootstrap/issues/5601) +* modify CSS loaded for modules with dependencies ([8074899](https://github.com/angular-ui/bootstrap/commit/8074899)), closes [#5698](https://github.com/angular-ui/bootstrap/issues/5698) +* **carousel:** use proper sort comparison ([434c1c5](https://github.com/angular-ui/bootstrap/commit/434c1c5)), closes [#5638](https://github.com/angular-ui/bootstrap/issues/5638) [#5702](https://github.com/angular-ui/bootstrap/issues/5702) +* **modal:** body content shift ([c83d0a8](https://github.com/angular-ui/bootstrap/commit/c83d0a8)), closes [#2631](https://github.com/angular-ui/bootstrap/issues/2631) [#5711](https://github.com/angular-ui/bootstrap/issues/5711) +* **popover:** rename title attribute ([cca1460](https://github.com/angular-ui/bootstrap/commit/cca1460)), closes [#5719](https://github.com/angular-ui/bootstrap/issues/5719) [#5721](https://github.com/angular-ui/bootstrap/issues/5721) +* **tab:** add support for tab deselect prevention ([e0fcc00](https://github.com/angular-ui/bootstrap/commit/e0fcc00)), closes [#5720](https://github.com/angular-ui/bootstrap/issues/5720) [#5723](https://github.com/angular-ui/bootstrap/issues/5723) +* **tab:** correctly identify index on removal ([03e6047](https://github.com/angular-ui/bootstrap/commit/03e6047)), closes [#5689](https://github.com/angular-ui/bootstrap/issues/5689) +* **tab:** fix event handler call ([d767afb](https://github.com/angular-ui/bootstrap/commit/d767afb)), closes [#5720](https://github.com/angular-ui/bootstrap/issues/5720) [#5738](https://github.com/angular-ui/bootstrap/issues/5738) + +### Features + +* add support for loading without CSS ([de504fb](https://github.com/angular-ui/bootstrap/commit/de504fb)), closes [#5696](https://github.com/angular-ui/bootstrap/issues/5696) +* **carousel:** disable prev/next arrows if at bounds ([a3964d4](https://github.com/angular-ui/bootstrap/commit/a3964d4)), closes [#5704](https://github.com/angular-ui/bootstrap/issues/5704) [#5708](https://github.com/angular-ui/bootstrap/issues/5708) +* **dateparser:** use 68 as the yy format pivot year ([701e0cb](https://github.com/angular-ui/bootstrap/commit/701e0cb)), closes [#5735](https://github.com/angular-ui/bootstrap/issues/5735) +* **datepicker:** deprecate literal usage ([fc02fd1](https://github.com/angular-ui/bootstrap/commit/fc02fd1)), closes [#5658](https://github.com/angular-ui/bootstrap/issues/5658) [#5732](https://github.com/angular-ui/bootstrap/issues/5732) +* **datepicker:** remove deprecated code ([ad91c82](https://github.com/angular-ui/bootstrap/commit/ad91c82)), closes [#5660](https://github.com/angular-ui/bootstrap/issues/5660) +* **datepicker:** watch for changes when falsy ([45165ba](https://github.com/angular-ui/bootstrap/commit/45165ba)), closes [#5672](https://github.com/angular-ui/bootstrap/issues/5672) [#5677](https://github.com/angular-ui/bootstrap/issues/5677) +* **datepickerPopup:** split off popup from datepicker ([fbc873c](https://github.com/angular-ui/bootstrap/commit/fbc873c)), closes [#5676](https://github.com/angular-ui/bootstrap/issues/5676) +* **dropdown:** remove $locationChangeSuccess listener ([c534cb4](https://github.com/angular-ui/bootstrap/commit/c534cb4)), closes [#5648](https://github.com/angular-ui/bootstrap/issues/5648) [#5680](https://github.com/angular-ui/bootstrap/issues/5680) +* **timepicker:** remove automatic padding when empty ([449c0d1](https://github.com/angular-ui/bootstrap/commit/449c0d1)), closes [#5293](https://github.com/angular-ui/bootstrap/issues/5293) [#5299](https://github.com/angular-ui/bootstrap/issues/5299) + + +### BREAKING CHANGES + +* carousel: This adds disabled CSS - there is a possibility this may break existing UI slightly for those adding custom CSS/making use of custom templates. Modify the template appropriately if necessary +* timepicker: This removes automatic padding done by the timepicker +when the input is empty - if that behavior is desired, write a custom +directive implementing it +* dropdown: The dropdown no longer will remain open on $locationChangeSuccess with autoclose set to disabled. Implement this logic inside app along with usage of isOpen two-way binding if this functionality is desired. +* datepickerPopup: The datepicker popup is no longer a part of the +datepicker module, but now a part of the datepickerPopup module. Please +change code accordingly if including specific modules +* datepicker: This removes inline datepicker attribute support and +attribute pass-throughs in the popup + + + + +## [1.2.5](https://github.com/angular-ui/bootstrap/compare/1.2.4...1.2.5) (2016-03-20) + + +### Bug Fixes + +* **datepicker:** dereference date initialization ([1b888d4](https://github.com/angular-ui/bootstrap/commit/1b888d4)), closes [#5643](https://github.com/angular-ui/bootstrap/issues/5643) [#5441](https://github.com/angular-ui/bootstrap/issues/5441) +* **datepicker:** fix today button disabled condition ([316e96c](https://github.com/angular-ui/bootstrap/commit/316e96c)), closes [#5637](https://github.com/angular-ui/bootstrap/issues/5637) [#5622](https://github.com/angular-ui/bootstrap/issues/5622) +* **modal:** dynamically fetch elements ([e15a22a](https://github.com/angular-ui/bootstrap/commit/e15a22a)), closes [#5630](https://github.com/angular-ui/bootstrap/issues/5630) [#5050](https://github.com/angular-ui/bootstrap/issues/5050) [#5421](https://github.com/angular-ui/bootstrap/issues/5421) +* **popover:** add popover-html css ([54ac06a](https://github.com/angular-ui/bootstrap/commit/54ac06a)), closes [#5603](https://github.com/angular-ui/bootstrap/issues/5603) [#5602](https://github.com/angular-ui/bootstrap/issues/5602) +* **tooltip:** css to support arrow positioning ([66c416c](https://github.com/angular-ui/bootstrap/commit/66c416c)), closes [#5610](https://github.com/angular-ui/bootstrap/issues/5610) [#5611](https://github.com/angular-ui/bootstrap/issues/5611) +* **typeahead:** reset errors when clearing ([bc7c55a](https://github.com/angular-ui/bootstrap/commit/bc7c55a)), closes [#5641](https://github.com/angular-ui/bootstrap/issues/5641) + +### Features + +* **accordion:** add bound panel-class support ([ffb5529](https://github.com/angular-ui/bootstrap/commit/ffb5529)), closes [#5368](https://github.com/angular-ui/bootstrap/issues/5368) [#5596](https://github.com/angular-ui/bootstrap/issues/5596) +* **modal:** exclude hidden elements from query ([5ef56e2](https://github.com/angular-ui/bootstrap/commit/5ef56e2)), closes [#5512](https://github.com/angular-ui/bootstrap/issues/5512) [#5644](https://github.com/angular-ui/bootstrap/issues/5644) +* **rating:** ability to disable rating reset ([8747b58](https://github.com/angular-ui/bootstrap/commit/8747b58)), closes [#5532](https://github.com/angular-ui/bootstrap/issues/5532) [#5631](https://github.com/angular-ui/bootstrap/issues/5631) +* **tab:** add select expressions ([bb36e40](https://github.com/angular-ui/bootstrap/commit/bb36e40)), closes [#5438](https://github.com/angular-ui/bootstrap/issues/5438) +* **timepicker:** add pad-hours support ([c7be087](https://github.com/angular-ui/bootstrap/commit/c7be087)), closes [#4288](https://github.com/angular-ui/bootstrap/issues/4288) [#5633](https://github.com/angular-ui/bootstrap/issues/5633) + + + + +## [1.2.4](https://github.com/angular-ui/bootstrap/compare/1.2.3...1.2.4) (2016-03-06) + + + + + +## [1.2.3](https://github.com/angular-ui/bootstrap/compare/1.2.2...1.2.3) (2016-03-06) + + +### Bug Fixes + +* **dropdown:** correctly update isOpen ([aac0d2b](https://github.com/angular-ui/bootstrap/commit/aac0d2b)), closes [#5589](https://github.com/angular-ui/bootstrap/issues/5589) [#3261](https://github.com/angular-ui/bootstrap/issues/3261) +* **modal:** switch to $animate ([dd62c73](https://github.com/angular-ui/bootstrap/commit/dd62c73)), closes [#5585](https://github.com/angular-ui/bootstrap/issues/5585) [#5298](https://github.com/angular-ui/bootstrap/issues/5298) +* **position:** auto position append to body ([a516178](https://github.com/angular-ui/bootstrap/commit/a516178)), closes [#5582](https://github.com/angular-ui/bootstrap/issues/5582) [#5576](https://github.com/angular-ui/bootstrap/issues/5576) +* **tabs:** support heading with : ([a945dd2](https://github.com/angular-ui/bootstrap/commit/a945dd2)), closes [#5590](https://github.com/angular-ui/bootstrap/issues/5590) [#5568](https://github.com/angular-ui/bootstrap/issues/5568) +* **typeahead:** change to original scope ([7949494](https://github.com/angular-ui/bootstrap/commit/7949494)), closes [#5588](https://github.com/angular-ui/bootstrap/issues/5588) [#5467](https://github.com/angular-ui/bootstrap/issues/5467) +* **typeahead:** update isOpen on escape ([313ba83](https://github.com/angular-ui/bootstrap/commit/313ba83)), closes [#5579](https://github.com/angular-ui/bootstrap/issues/5579) [#5587](https://github.com/angular-ui/bootstrap/issues/5587) + +### Features + +* **datepicker:** add helpers for styling ([c15dcbd](https://github.com/angular-ui/bootstrap/commit/c15dcbd)), closes [#5580](https://github.com/angular-ui/bootstrap/issues/5580) + + + + +## [1.2.2](https://github.com/angular-ui/bootstrap/compare/1.2.1...1.2.2) (2016-03-03) + + +### Bug Fixes + +* **Gruntfile:** add per module flag ([a816251](https://github.com/angular-ui/bootstrap/commit/a816251)), closes [#5567](https://github.com/angular-ui/bootstrap/issues/5567) +* **modal:** fire ng-leave on close ([299f751](https://github.com/angular-ui/bootstrap/commit/299f751)), closes [#5556](https://github.com/angular-ui/bootstrap/issues/5556) [#5399](https://github.com/angular-ui/bootstrap/issues/5399) +* **modal:** fix bindToController props ([5e43870](https://github.com/angular-ui/bootstrap/commit/5e43870)), closes [#5569](https://github.com/angular-ui/bootstrap/issues/5569) +* **position:** add CSS for webpack support ([d68086f](https://github.com/angular-ui/bootstrap/commit/d68086f)), closes [#5561](https://github.com/angular-ui/bootstrap/issues/5561) +* **rating:** fix usage of aria-valuetext ([4369800](https://github.com/angular-ui/bootstrap/commit/4369800)), closes [#5573](https://github.com/angular-ui/bootstrap/issues/5573) [#5571](https://github.com/angular-ui/bootstrap/issues/5571) +* **tabs:** update doc and fix tab demo ([9d74d6c](https://github.com/angular-ui/bootstrap/commit/9d74d6c)), closes [#5575](https://github.com/angular-ui/bootstrap/issues/5575) [#5551](https://github.com/angular-ui/bootstrap/issues/5551) +* **tests:** add style tag once ([cc2d1b9](https://github.com/angular-ui/bootstrap/commit/cc2d1b9)), closes [#5557](https://github.com/angular-ui/bootstrap/issues/5557) [#5548](https://github.com/angular-ui/bootstrap/issues/5548) +* **tooltip:** import CSS ([c8922a2](https://github.com/angular-ui/bootstrap/commit/c8922a2)), closes [#5552](https://github.com/angular-ui/bootstrap/issues/5552) + + + + +## [1.2.1](https://github.com/angular-ui/bootstrap/compare/1.2.0...1.2.1) (2016-02-27) + + +### Bug Fixes + +* **popover:** add missing selector ([70c1c90](https://github.com/angular-ui/bootstrap/commit/70c1c90)), closes [#5549](https://github.com/angular-ui/bootstrap/issues/5549) + + + + +# [1.2.0](https://github.com/angular-ui/bootstrap/compare/1.1.2...1.2.0) (2016-02-26) + + +### Bug Fixes + +* **buttons:** fix uncheckable attribute ([b245242](https://github.com/angular-ui/bootstrap/commit/b245242)), closes [#5451](https://github.com/angular-ui/bootstrap/issues/5451) [#5412](https://github.com/angular-ui/bootstrap/issues/5412) +* **dateparser:** append end of format ([5c565df](https://github.com/angular-ui/bootstrap/commit/5c565df)), closes [#5385](https://github.com/angular-ui/bootstrap/issues/5385) [#5387](https://github.com/angular-ui/bootstrap/issues/5387) +* **datepicker:** adjust datepicker header buttons ([f125537](https://github.com/angular-ui/bootstrap/commit/f125537)), closes [#5393](https://github.com/angular-ui/bootstrap/issues/5393) [#5392](https://github.com/angular-ui/bootstrap/issues/5392) +* **datepicker:** assign initDate correctly to the controller ([8769749](https://github.com/angular-ui/bootstrap/commit/8769749)), closes [#5541](https://github.com/angular-ui/bootstrap/issues/5541) +* **datepicker:** check if initDate is valid ([ab59413](https://github.com/angular-ui/bootstrap/commit/ab59413)), closes [#5190](https://github.com/angular-ui/bootstrap/issues/5190) [#5266](https://github.com/angular-ui/bootstrap/issues/5266) +* **datepicker:** default to current date ([4b2ee0f](https://github.com/angular-ui/bootstrap/commit/4b2ee0f)), closes [#5395](https://github.com/angular-ui/bootstrap/issues/5395) [#5190](https://github.com/angular-ui/bootstrap/issues/5190) +* **datepicker:** fallback to current date ([e4fc201](https://github.com/angular-ui/bootstrap/commit/e4fc201)), closes [#5418](https://github.com/angular-ui/bootstrap/issues/5418) [#5417](https://github.com/angular-ui/bootstrap/issues/5417) +* **datepicker:** fix popup updateOn: default support ([47e412e](https://github.com/angular-ui/bootstrap/commit/47e412e)), closes [#5529](https://github.com/angular-ui/bootstrap/issues/5529) +* **datepicker:** stop event bubbling from buttons ([e283cea](https://github.com/angular-ui/bootstrap/commit/e283cea)), closes [#5400](https://github.com/angular-ui/bootstrap/issues/5400) [#5347](https://github.com/angular-ui/bootstrap/issues/5347) +* **modal:** fix modal rendered promise ([42fb486](https://github.com/angular-ui/bootstrap/commit/42fb486)), closes [#5401](https://github.com/angular-ui/bootstrap/issues/5401) [#5331](https://github.com/angular-ui/bootstrap/issues/5331) +* **modal:** fix race condition with openedClass ([979fe0b](https://github.com/angular-ui/bootstrap/commit/979fe0b)), closes [#5483](https://github.com/angular-ui/bootstrap/issues/5483) +* **position:** getRawNode to handle select/ul elem ([8b3e86f](https://github.com/angular-ui/bootstrap/commit/8b3e86f)), closes [#5491](https://github.com/angular-ui/bootstrap/issues/5491) [#5354](https://github.com/angular-ui/bootstrap/issues/5354) +* **position:** move inline styles to a class ([4bb178a](https://github.com/angular-ui/bootstrap/commit/4bb178a)), closes [#5535](https://github.com/angular-ui/bootstrap/issues/5535) [#5470](https://github.com/angular-ui/bootstrap/issues/5470) +* **progressbar:** fix default max value ([258b341](https://github.com/angular-ui/bootstrap/commit/258b341)), closes [#5374](https://github.com/angular-ui/bootstrap/issues/5374) [#5373](https://github.com/angular-ui/bootstrap/issues/5373) +* **rating:** change to read-only ([d5a757d](https://github.com/angular-ui/bootstrap/commit/d5a757d)), closes [#5371](https://github.com/angular-ui/bootstrap/issues/5371) [#5435](https://github.com/angular-ui/bootstrap/issues/5435) +* **tab:** make active optional ([d1553a4](https://github.com/angular-ui/bootstrap/commit/d1553a4)), closes [#5489](https://github.com/angular-ui/bootstrap/issues/5489) +* **tabs:** adding bootstrap 4 specific class ([3814fe3](https://github.com/angular-ui/bootstrap/commit/3814fe3)), closes [#5488](https://github.com/angular-ui/bootstrap/issues/5488) +* **typeahead:** fix shift tab ([64e3127](https://github.com/angular-ui/bootstrap/commit/64e3127)), closes [#5494](https://github.com/angular-ui/bootstrap/issues/5494) [#5493](https://github.com/angular-ui/bootstrap/issues/5493) +* **typeahead:** Fix tab index for hint input ([4178500](https://github.com/angular-ui/bootstrap/commit/4178500)), closes [#5478](https://github.com/angular-ui/bootstrap/issues/5478) [#5492](https://github.com/angular-ui/bootstrap/issues/5492) + +### Features + +* **accordion:** use attribute for heading insertion ([ca6b177](https://github.com/angular-ui/bootstrap/commit/ca6b177)), closes [#5324](https://github.com/angular-ui/bootstrap/issues/5324) [#5396](https://github.com/angular-ui/bootstrap/issues/5396) +* **datepicker:** add deprecation notices ([10eac7c](https://github.com/angular-ui/bootstrap/commit/10eac7c)), closes [#5415](https://github.com/angular-ui/bootstrap/issues/5415) +* **datepicker:** implement auto position ([f9903ba](https://github.com/angular-ui/bootstrap/commit/f9903ba)), closes [#5444](https://github.com/angular-ui/bootstrap/issues/5444) +* **datepicker:** move attribute support to options ([d880aea](https://github.com/angular-ui/bootstrap/commit/d880aea)), closes [#5528](https://github.com/angular-ui/bootstrap/issues/5528) +* **datepicker:** move datepicker mode to options ([74a1d04](https://github.com/angular-ui/bootstrap/commit/74a1d04)), closes [#5526](https://github.com/angular-ui/bootstrap/issues/5526) +* **datepicker:** pass options from popup to inline ([3e10184](https://github.com/angular-ui/bootstrap/commit/3e10184)), closes [#5355](https://github.com/angular-ui/bootstrap/issues/5355) +* **datepicker:** preserve popup attributes ([bbc4912](https://github.com/angular-ui/bootstrap/commit/bbc4912)), closes [#5537](https://github.com/angular-ui/bootstrap/issues/5537) +* **modal:** Call $onInit on controller if defined ([8349758](https://github.com/angular-ui/bootstrap/commit/8349758)), closes [#5472](https://github.com/angular-ui/bootstrap/issues/5472) [#5509](https://github.com/angular-ui/bootstrap/issues/5509) +* **pagination:** add custom label support ([785c373](https://github.com/angular-ui/bootstrap/commit/785c373)), closes [#2532](https://github.com/angular-ui/bootstrap/issues/2532) [#5547](https://github.com/angular-ui/bootstrap/issues/5547) +* **position:** add isScrollable ([3c0a7cd](https://github.com/angular-ui/bootstrap/commit/3c0a7cd)), closes [#5508](https://github.com/angular-ui/bootstrap/issues/5508) +* **tab:** add template-url support ([54c51c4](https://github.com/angular-ui/bootstrap/commit/54c51c4)), closes [#5405](https://github.com/angular-ui/bootstrap/issues/5405) [#5443](https://github.com/angular-ui/bootstrap/issues/5443) +* **tabs:** support optional classes on tab ([8364e76](https://github.com/angular-ui/bootstrap/commit/8364e76)), closes [#5430](https://github.com/angular-ui/bootstrap/issues/5430) +* **tooltip:** arrow position ([fa17c48](https://github.com/angular-ui/bootstrap/commit/fa17c48)), closes [#5464](https://github.com/angular-ui/bootstrap/issues/5464) [#5502](https://github.com/angular-ui/bootstrap/issues/5502) +* **typeahead:** add dynamic min length support ([f81d440](https://github.com/angular-ui/bootstrap/commit/f81d440)), closes [#5363](https://github.com/angular-ui/bootstrap/issues/5363) + + +### BREAKING CHANGES + +* rating: Attribute supported has been changed to `read-only` +from `readonly` +* accordion: This changes to use the `uibAccordionHeader` attribute instead of a `` element for inserting the custom header HTML. If you use a custom template for the accordion group, please add this attribute to the appropriate location. +* datepicker: This requires $event to be passed in the second +argument of the select function and in the close argument for the popup +template +* datepicker: This adds extra CSS for the datepicker for the left and right header buttons - one can override this appropriately with any selector of class priority higher than 1 +* datepicker: This breaks any snake-cased key usage, i.e. +`show-weeks`. Convert all keys used to camelCase. +* tab: Make the `active` attribute optional and move to `tabset` element. +* carousel: the `active` attribute is now required on the `uib-carousel` element and a unique `index` attribute is required on each `uib-slide` element. + + + + +## [1.1.2](https://github.com/angular-ui/bootstrap/compare/1.1.1...1.1.2) (2016-02-01) + + +### Bug Fixes + +* **datepicker:** fix validation for M! & d! ([c3b1431](https://github.com/angular-ui/bootstrap/commit/c3b1431)), closes [#5376](https://github.com/angular-ui/bootstrap/issues/5376) [#5225](https://github.com/angular-ui/bootstrap/issues/5225) +* **modal:** ensure shift+tab is trapped in modal ([d2621e3](https://github.com/angular-ui/bootstrap/commit/d2621e3)), closes [#5294](https://github.com/angular-ui/bootstrap/issues/5294) [#5229](https://github.com/angular-ui/bootstrap/issues/5229) +* **tooltip:** prevent closing on $locationChangeSuccess ([48c9cd8](https://github.com/angular-ui/bootstrap/commit/48c9cd8)), closes [#5360](https://github.com/angular-ui/bootstrap/issues/5360) [#5337](https://github.com/angular-ui/bootstrap/issues/5337) +* **typeahead:** add guard for no $viewValue ([dbe9e81](https://github.com/angular-ui/bootstrap/commit/dbe9e81)), closes [#5358](https://github.com/angular-ui/bootstrap/issues/5358) [#5357](https://github.com/angular-ui/bootstrap/issues/5357) + +### Features + +* **accordion:** add aria tags ([61cdef1](https://github.com/angular-ui/bootstrap/commit/61cdef1)), closes [#5338](https://github.com/angular-ui/bootstrap/issues/5338) +* **datepicker:** add datepickerOptions support ([e58c42c](https://github.com/angular-ui/bootstrap/commit/e58c42c)), closes [#5340](https://github.com/angular-ui/bootstrap/issues/5340) + + + + +## [1.1.1](https://github.com/angular-ui/bootstrap/compare/1.1.0...1.1.1) (2016-01-25) + + +### Bug Fixes + +* **datepicker:** allow using min/max mode/date with datepickerOptions ([97af6a9](https://github.com/angular-ui/bootstrap/commit/97af6a9)), closes [#5334](https://github.com/angular-ui/bootstrap/issues/5334) [#5315](https://github.com/angular-ui/bootstrap/issues/5315) +* **modal:** fix modal closed resolution ([d5a48ea](https://github.com/angular-ui/bootstrap/commit/d5a48ea)), closes [#5322](https://github.com/angular-ui/bootstrap/issues/5322) [#5326](https://github.com/angular-ui/bootstrap/issues/5326) + + + + +# [1.1.0](https://github.com/angular-ui/bootstrap/compare/v1.0.3...v1.1.0) (2016-01-18) + + +## Bug Fixes + +* **collapse:** avoid initial animation when expanded ([57219aa](https://github.com/angular-ui/bootstrap/commit/57219aa)), closes [#5199](https://github.com/angular-ui/bootstrap/issues/5199) [#4414](https://github.com/angular-ui/bootstrap/issues/4414) [#5192](https://github.com/angular-ui/bootstrap/issues/5192) +* **datepicker:** allow expressions for minMode/maxMode ([7e93ec9](https://github.com/angular-ui/bootstrap/commit/7e93ec9)), closes [#5264](https://github.com/angular-ui/bootstrap/issues/5264) [#5268](https://github.com/angular-ui/bootstrap/issues/5268) [#5240](https://github.com/angular-ui/bootstrap/issues/5240) +* **datepicker:** fix popup CSS ([b3e6d17](https://github.com/angular-ui/bootstrap/commit/b3e6d17)), closes [#5258](https://github.com/angular-ui/bootstrap/issues/5258) +* **datepicker:** fix popup element garbage collection ([2cb3bc2](https://github.com/angular-ui/bootstrap/commit/2cb3bc2)), closes [#5274](https://github.com/angular-ui/bootstrap/issues/5274) [#5058](https://github.com/angular-ui/bootstrap/issues/5058) +* **datepicker:** pass through null ([78ba137](https://github.com/angular-ui/bootstrap/commit/78ba137)), closes [#5275](https://github.com/angular-ui/bootstrap/issues/5275) [#5238](https://github.com/angular-ui/bootstrap/issues/5238) +* **modal:** change to compile before appending ([1dbad8a](https://github.com/angular-ui/bootstrap/commit/1dbad8a)), closes [#5224](https://github.com/angular-ui/bootstrap/issues/5224) [#5183](https://github.com/angular-ui/bootstrap/issues/5183) +* **paging:** garbage collect parent $watchers ([32b88f1](https://github.com/angular-ui/bootstrap/commit/32b88f1)), closes [#5276](https://github.com/angular-ui/bootstrap/issues/5276) +* **position:** positionArrow descendant selector ([9f4c3a5](https://github.com/angular-ui/bootstrap/commit/9f4c3a5), [e8b6a83](https://github.com/angular-ui/bootstrap/commit/e8b6a83)), closes [#5246](https://github.com/angular-ui/bootstrap/issues/5246) [#5230](https://github.com/angular-ui/bootstrap/issues/5230) +* **tab:** revert template change ([907c851](https://github.com/angular-ui/bootstrap/commit/907c851)), closes [#5262](https://github.com/angular-ui/bootstrap/issues/5262) [#5254](https://github.com/angular-ui/bootstrap/issues/5254) +* **timepicker:** garbage collect parent watchers ([3f809af](https://github.com/angular-ui/bootstrap/commit/3f809af)), closes [#5277](https://github.com/angular-ui/bootstrap/issues/5277) +* **tooltip:** fix addClass signature ([6a2d91b](https://github.com/angular-ui/bootstrap/commit/6a2d91b)), closes [#5235](https://github.com/angular-ui/bootstrap/issues/5235) +* **typeahead:** add guard for destroyed $scope ([e105e85](https://github.com/angular-ui/bootstrap/commit/e105e85)), closes [#5267](https://github.com/angular-ui/bootstrap/issues/5267) +* **typeahead:** fix css selector ([00ac93e](https://github.com/angular-ui/bootstrap/commit/00ac93e)), closes [#5251](https://github.com/angular-ui/bootstrap/issues/5251) + +## Features + +* **collapse:** add callback hooks ([446364a](https://github.com/angular-ui/bootstrap/commit/446364a)), closes [#5194](https://github.com/angular-ui/bootstrap/issues/5194) [#5226](https://github.com/angular-ui/bootstrap/issues/5226) +* **datepicker:** extract inline styles to stylesheet ([6843ab6](https://github.com/angular-ui/bootstrap/commit/6843ab6)), closes [#5215](https://github.com/angular-ui/bootstrap/issues/5215) +* **datepicker:** use $locale for starting day ([332eefb](https://github.com/angular-ui/bootstrap/commit/332eefb)), closes [#4954](https://github.com/angular-ui/bootstrap/issues/4954) [#5281](https://github.com/angular-ui/bootstrap/issues/5281) +* **timepicker:** add placeholder for seconds input ([717ea69](https://github.com/angular-ui/bootstrap/commit/717ea69)), closes [#5257](https://github.com/angular-ui/bootstrap/issues/5257) +* **timepicker:** move inline styles to stylesheet ([b658b03](https://github.com/angular-ui/bootstrap/commit/b658b03)), closes [#5218](https://github.com/angular-ui/bootstrap/issues/5218) +* **typeahead:** add title to matches ([f523361](https://github.com/angular-ui/bootstrap/commit/f523361)), closes [#5252](https://github.com/angular-ui/bootstrap/issues/5252) +* **typeahead:** move inline style to stylesheet ([e8201d1](https://github.com/angular-ui/bootstrap/commit/e8201d1)), closes [#5219](https://github.com/angular-ui/bootstrap/issues/5219) + + +## BREAKING CHANGES + +* tab: This undoes the prior change to the template using div +elements. If one wishes to use div elements, one must override the +template in one's app and provide the necessary CSS + + + + +# [1.0.3](https://github.com/angular-ui/bootstrap/compare/1.0.2...1.0.3) (2016-01-12) + + +## Bug Fixes + +* **timepicker:** fix injection ([627a451](https://github.com/angular-ui/bootstrap/commit/627a451)) + + + + +# [1.0.2](https://github.com/angular-ui/bootstrap/compare/1.0.1...1.0.2) (2016-01-12) + + +## Bug Fixes + +* **tabs:** fix csp check ([74be568](https://github.com/angular-ui/bootstrap/commit/74be568)), closes [#5214](https://github.com/angular-ui/bootstrap/issues/5214) + + + + +# [1.0.1](https://github.com/angular-ui/bootstrap/compare/1.0.0...1.0.1) (2016-01-12) + + +## Bug Fixes + +* **build:** add ; after script ([9d0269e](https://github.com/angular-ui/bootstrap/commit/9d0269e)), closes [#5197](https://github.com/angular-ui/bootstrap/issues/5197) [#5196](https://github.com/angular-ui/bootstrap/issues/5196) +* **carousel:** reset active for buffered transitions ([36fca74](https://github.com/angular-ui/bootstrap/commit/36fca74)), closes [#5195](https://github.com/angular-ui/bootstrap/issues/5195) [#5178](https://github.com/angular-ui/bootstrap/issues/5178) +* **datepicker:** fix error message ([aa010b0](https://github.com/angular-ui/bootstrap/commit/aa010b0)), closes [#5198](https://github.com/angular-ui/bootstrap/issues/5198) [#5191](https://github.com/angular-ui/bootstrap/issues/5191) +* **datepicker:** fix usage of non-standard format ([428beaf](https://github.com/angular-ui/bootstrap/commit/428beaf)), closes [#5188](https://github.com/angular-ui/bootstrap/issues/5188) [#5187](https://github.com/angular-ui/bootstrap/issues/5187) +* **timepicker:** prevent mixture of numbers and letters ([9e9c6cf](https://github.com/angular-ui/bootstrap/commit/9e9c6cf)), closes [#5201](https://github.com/angular-ui/bootstrap/issues/5201) [#5085](https://github.com/angular-ui/bootstrap/issues/5085) +* **timepicker:** use correct disabled expression ([71afeeb](https://github.com/angular-ui/bootstrap/commit/71afeeb)), closes [#5185](https://github.com/angular-ui/bootstrap/issues/5185) [#5182](https://github.com/angular-ui/bootstrap/issues/5182) +* **typeahead:** scroll only parent element ([3dac5e3](https://github.com/angular-ui/bootstrap/commit/3dac5e3)), closes [#5212](https://github.com/angular-ui/bootstrap/issues/5212) [#5180](https://github.com/angular-ui/bootstrap/issues/5180) + +## Features + +* **tabs:** add CSS to css file ([db7adf7](https://github.com/angular-ui/bootstrap/commit/db7adf7)), closes [#5211](https://github.com/angular-ui/bootstrap/issues/5211) +* **timepicker:** add templateUrl in timepickerConfig ([4b0e381](https://github.com/angular-ui/bootstrap/commit/4b0e381)), closes [#5087](https://github.com/angular-ui/bootstrap/issues/5087) [#5200](https://github.com/angular-ui/bootstrap/issues/5200) + + + + +# [1.0.0](https://github.com/angular-ui/bootstrap/compare/0.14.3...1.0.0) (2016-01-08) + + +## Bug Fixes + +* **accordion:** ensure panelOpen class is present ([0917623](https://github.com/angular-ui/bootstrap/commit/0917623)), closes [#4849](https://github.com/angular-ui/bootstrap/issues/4849) [#4870](https://github.com/angular-ui/bootstrap/issues/4870) +* **accordion:** fix unexpected routing ([df211bd](https://github.com/angular-ui/bootstrap/commit/df211bd)), closes [#4792](https://github.com/angular-ui/bootstrap/issues/4792) +* **carousel:** decouple animation information from DOM ([38c1b14](https://github.com/angular-ui/bootstrap/commit/38c1b14)), closes [#4737](https://github.com/angular-ui/bootstrap/issues/4737) [#4516](https://github.com/angular-ui/bootstrap/issues/4516) +* **carousel:** fix conditions for animation ([12a37e0](https://github.com/angular-ui/bootstrap/commit/12a37e0)), closes [#4974](https://github.com/angular-ui/bootstrap/issues/4974) [#4972](https://github.com/angular-ui/bootstrap/issues/4972) +* **carousel:** remove version checks ([9d93af1](https://github.com/angular-ui/bootstrap/commit/9d93af1)), closes [#4122](https://github.com/angular-ui/bootstrap/issues/4122) [#4774](https://github.com/angular-ui/bootstrap/issues/4774) +* **carousel:** resolve rendering issues ([763cfd9](https://github.com/angular-ui/bootstrap/commit/763cfd9)), closes [#4984](https://github.com/angular-ui/bootstrap/issues/4984) [#4361](https://github.com/angular-ui/bootstrap/issues/4361) [#4823](https://github.com/angular-ui/bootstrap/issues/4823) [#4969](https://github.com/angular-ui/bootstrap/issues/4969) +* **collapse:** set initial state to avoid animation ([5ad08ff](https://github.com/angular-ui/bootstrap/commit/5ad08ff)), closes [#5075](https://github.com/angular-ui/bootstrap/issues/5075) [#4848](https://github.com/angular-ui/bootstrap/issues/4848) [#4885](https://github.com/angular-ui/bootstrap/issues/4885) +* **dateparser:** baseDate only care about dates ([21b2297](https://github.com/angular-ui/bootstrap/commit/21b2297)), closes [#4767](https://github.com/angular-ui/bootstrap/issues/4767) +* **dateparser:** enforce order of regex construction ([83d1435](https://github.com/angular-ui/bootstrap/commit/83d1435)), closes [#4810](https://github.com/angular-ui/bootstrap/issues/4810) [#4808](https://github.com/angular-ui/bootstrap/issues/4808) +* **datepicker:** active date should be model if present ([9019298](https://github.com/angular-ui/bootstrap/commit/9019298)), closes [#5082](https://github.com/angular-ui/bootstrap/issues/5082) [#5081](https://github.com/angular-ui/bootstrap/issues/5081) +* **datepicker:** correctly display pre-100 years ([beabb4a](https://github.com/angular-ui/bootstrap/commit/beabb4a)), closes [#4812](https://github.com/angular-ui/bootstrap/issues/4812) [#4032](https://github.com/angular-ui/bootstrap/issues/4032) +* **datepicker:** correctly set minMode/maxMode ([1524080](https://github.com/angular-ui/bootstrap/commit/1524080)), closes [#5093](https://github.com/angular-ui/bootstrap/issues/5093) [#5090](https://github.com/angular-ui/bootstrap/issues/5090) +* **datepicker:** fix minDate/maxDate with literals ([e7f709f](https://github.com/angular-ui/bootstrap/commit/e7f709f)), closes [#4841](https://github.com/angular-ui/bootstrap/issues/4841) [#3437](https://github.com/angular-ui/bootstrap/issues/3437) +* **datepicker:** stop propagation of esc in popup ([000d6c3](https://github.com/angular-ui/bootstrap/commit/000d6c3)), closes [#5074](https://github.com/angular-ui/bootstrap/issues/5074) [#5013](https://github.com/angular-ui/bootstrap/issues/5013) +* **datepicker:** update with alternative format ([fd88dcb](https://github.com/angular-ui/bootstrap/commit/fd88dcb)), closes [#5014](https://github.com/angular-ui/bootstrap/issues/5014) +* **debounce:** fix argument slicing ([e196be8](https://github.com/angular-ui/bootstrap/commit/e196be8)), closes [#4859](https://github.com/angular-ui/bootstrap/issues/4859) [#4860](https://github.com/angular-ui/bootstrap/issues/4860) +* **dropdown:** do not close on right click ([bf1768e](https://github.com/angular-ui/bootstrap/commit/bf1768e)), closes [#5052](https://github.com/angular-ui/bootstrap/issues/5052) [#5051](https://github.com/angular-ui/bootstrap/issues/5051) +* **dropdown:** remove class support for uib-dropdown-menu directive ([43535cf](https://github.com/angular-ui/bootstrap/commit/43535cf)), closes [#4753](https://github.com/angular-ui/bootstrap/issues/4753) +* **dropdown:** remove extra uib-keyboard-nav ([57f72b2](https://github.com/angular-ui/bootstrap/commit/57f72b2)), closes [#4891](https://github.com/angular-ui/bootstrap/issues/4891) +* **modal:** add focus check for IE ([a5c2a5b](https://github.com/angular-ui/bootstrap/commit/a5c2a5b)), closes [#5097](https://github.com/angular-ui/bootstrap/issues/5097) [#5096](https://github.com/angular-ui/bootstrap/issues/5096) +* **modal:** clean up animation when disabled ([972dee6](https://github.com/angular-ui/bootstrap/commit/972dee6)), closes [#4740](https://github.com/angular-ui/bootstrap/issues/4740) [#4672](https://github.com/angular-ui/bootstrap/issues/4672) +* **modal:** fix bindToController ([b8969d1](https://github.com/angular-ui/bootstrap/commit/b8969d1)), closes [#5048](https://github.com/angular-ui/bootstrap/issues/5048) [#5039](https://github.com/angular-ui/bootstrap/issues/5039) +* **modal:** retain focus if child has focus ([726ccc3](https://github.com/angular-ui/bootstrap/commit/726ccc3)), closes [#4904](https://github.com/angular-ui/bootstrap/issues/4904) [#4903](https://github.com/angular-ui/bootstrap/issues/4903) +* **modal:** trap tabbing regardless of config ([c0f1027](https://github.com/angular-ui/bootstrap/commit/c0f1027)), closes [#5010](https://github.com/angular-ui/bootstrap/issues/5010) [#4990](https://github.com/angular-ui/bootstrap/issues/4990) +* **pagination:** retain model on initialization ([30099a0](https://github.com/angular-ui/bootstrap/commit/30099a0)), closes [#3786](https://github.com/angular-ui/bootstrap/issues/3786) [#4783](https://github.com/angular-ui/bootstrap/issues/4783) [#2956](https://github.com/angular-ui/bootstrap/issues/2956) +* **tab:** fix unexpected routing ([d59083b](https://github.com/angular-ui/bootstrap/commit/d59083b)), closes [#4793](https://github.com/angular-ui/bootstrap/issues/4793) [#3266](https://github.com/angular-ui/bootstrap/issues/3266) +* **timepicker:** correct meridian toggle condition ([09ad740](https://github.com/angular-ui/bootstrap/commit/09ad740)), closes [#4435](https://github.com/angular-ui/bootstrap/issues/4435) +* **tooltip:** race condition when setting position ([429ddc1](https://github.com/angular-ui/bootstrap/commit/429ddc1)), closes [#4765](https://github.com/angular-ui/bootstrap/issues/4765) [#4757](https://github.com/angular-ui/bootstrap/issues/4757) +* **typeahead:** allow parent to be required ([3aa41f0](https://github.com/angular-ui/bootstrap/commit/3aa41f0)), closes [#4800](https://github.com/angular-ui/bootstrap/issues/4800) [#2679](https://github.com/angular-ui/bootstrap/issues/2679) +* **typeahead:** clear typeahead input when editable is false ([1d9294c](https://github.com/angular-ui/bootstrap/commit/1d9294c)), closes [#1620](https://github.com/angular-ui/bootstrap/issues/1620) [#4265](https://github.com/angular-ui/bootstrap/issues/4265) [#4752](https://github.com/angular-ui/bootstrap/issues/4752) +* **typeahead:** fix programmatic focus issue ([cab0945](https://github.com/angular-ui/bootstrap/commit/cab0945)), closes [#5150](https://github.com/angular-ui/bootstrap/issues/5150) [#764](https://github.com/angular-ui/bootstrap/issues/764) +* **typeahead:** use correct selector ([e1e6e1b](https://github.com/angular-ui/bootstrap/commit/e1e6e1b)), closes [#5168](https://github.com/angular-ui/bootstrap/issues/5168) [#5167](https://github.com/angular-ui/bootstrap/issues/5167) +* allow library to be loaded async ([a851636](https://github.com/angular-ui/bootstrap/commit/a851636)), closes [#4775](https://github.com/angular-ui/bootstrap/issues/4775) [#3665](https://github.com/angular-ui/bootstrap/issues/3665) + +## Features + +* **accordion:** remove deprecated code ([0010aff](https://github.com/angular-ui/bootstrap/commit/0010aff)), closes [#4706](https://github.com/angular-ui/bootstrap/issues/4706) +* **alert:** remove deprecated code ([21e852b](https://github.com/angular-ui/bootstrap/commit/21e852b)), closes [#4714](https://github.com/angular-ui/bootstrap/issues/4714) +* **buttons:** add uib-uncheckable support ([b77618e](https://github.com/angular-ui/bootstrap/commit/b77618e)), closes [#3604](https://github.com/angular-ui/bootstrap/issues/3604) [#4791](https://github.com/angular-ui/bootstrap/issues/4791) +* **buttons:** remove deprecated code ([b549263](https://github.com/angular-ui/bootstrap/commit/b549263)), closes [#4716](https://github.com/angular-ui/bootstrap/issues/4716) +* **carousel:** expose next and prev on controller ([9b80ee1](https://github.com/angular-ui/bootstrap/commit/9b80ee1)), closes [#4851](https://github.com/angular-ui/bootstrap/issues/4851) [#4853](https://github.com/angular-ui/bootstrap/issues/4853) +* **carousel:** remove deprecated code ([b159b21](https://github.com/angular-ui/bootstrap/commit/b159b21)), closes [#4717](https://github.com/angular-ui/bootstrap/issues/4717) +* **collapse:** remove deprecated code ([bc004df](https://github.com/angular-ui/bootstrap/commit/bc004df)), closes [#4715](https://github.com/angular-ui/bootstrap/issues/4715) +* **dateparser:** add M! and d! support ([b1cfc57](https://github.com/angular-ui/bootstrap/commit/b1cfc57)), closes [#4805](https://github.com/angular-ui/bootstrap/issues/4805) [#4809](https://github.com/angular-ui/bootstrap/issues/4809) +* **dateparser:** add new format support ([ea388b3](https://github.com/angular-ui/bootstrap/commit/ea388b3)), closes [#3418](https://github.com/angular-ui/bootstrap/issues/3418) [#4833](https://github.com/angular-ui/bootstrap/issues/4833) +* **dateparser:** add support for literals ([1c79888](https://github.com/angular-ui/bootstrap/commit/1c79888)), closes [#3914](https://github.com/angular-ui/bootstrap/issues/3914) [#4426](https://github.com/angular-ui/bootstrap/issues/4426) +* **dateparser:** add Z support ([2fb812b](https://github.com/angular-ui/bootstrap/commit/2fb812b)), closes [#4831](https://github.com/angular-ui/bootstrap/issues/4831) +* **dateparser:** change template literal from ' to ` ([9513f10](https://github.com/angular-ui/bootstrap/commit/9513f10)), closes [#4880](https://github.com/angular-ui/bootstrap/issues/4880) [#4936](https://github.com/angular-ui/bootstrap/issues/4936) [#4938](https://github.com/angular-ui/bootstrap/issues/4938) +* **dateparser:** remove deprecated code ([2d68f41](https://github.com/angular-ui/bootstrap/commit/2d68f41)), closes [#4718](https://github.com/angular-ui/bootstrap/issues/4718) +* **datepicker:** add allowInvalid support ([2460e42](https://github.com/angular-ui/bootstrap/commit/2460e42)), closes [#4694](https://github.com/angular-ui/bootstrap/issues/4694) [#4837](https://github.com/angular-ui/bootstrap/issues/4837) +* **datepicker:** add disabled and ngDisabled support ([434c602](https://github.com/angular-ui/bootstrap/commit/434c602)), closes [#4059](https://github.com/angular-ui/bootstrap/issues/4059) [#4814](https://github.com/angular-ui/bootstrap/issues/4814) +* **datepicker:** add semantic classes ([97c4333](https://github.com/angular-ui/bootstrap/commit/97c4333)), closes [#4761](https://github.com/angular-ui/bootstrap/issues/4761) +* **datepicker:** add timezone support ([09098f8](https://github.com/angular-ui/bootstrap/commit/09098f8)), closes [#5062](https://github.com/angular-ui/bootstrap/issues/5062) +* **datepicker:** implements alternative format support ([8bfeda0](https://github.com/angular-ui/bootstrap/commit/8bfeda0)), closes [#4951](https://github.com/angular-ui/bootstrap/issues/4951) [#4952](https://github.com/angular-ui/bootstrap/issues/4952) +* **datepicker:** pass through attrs in popup ([26d3103](https://github.com/angular-ui/bootstrap/commit/26d3103)), closes [#4863](https://github.com/angular-ui/bootstrap/issues/4863) [#3338](https://github.com/angular-ui/bootstrap/issues/3338) +* **datepicker:** remove deprecated code ([2fc3f21](https://github.com/angular-ui/bootstrap/commit/2fc3f21)), closes [#4708](https://github.com/angular-ui/bootstrap/issues/4708) +* **datepicker:** yearRange -> yearRows and yearColumns ([b784422](https://github.com/angular-ui/bootstrap/commit/b784422)), closes [#3348](https://github.com/angular-ui/bootstrap/issues/3348) [#4970](https://github.com/angular-ui/bootstrap/issues/4970) +* **dropdown:** add `append-to` support ([809ecdb](https://github.com/angular-ui/bootstrap/commit/809ecdb)), closes [#4467](https://github.com/angular-ui/bootstrap/issues/4467) [#4488](https://github.com/angular-ui/bootstrap/issues/4488) +* **dropdown:** add open class support ([0495ff0](https://github.com/angular-ui/bootstrap/commit/0495ff0)), closes [#4466](https://github.com/angular-ui/bootstrap/issues/4466) [#4794](https://github.com/angular-ui/bootstrap/issues/4794) +* **dropdown:** remove deprecated code ([ca3a343](https://github.com/angular-ui/bootstrap/commit/ca3a343)), closes [#4719](https://github.com/angular-ui/bootstrap/issues/4719) +* **modal:** add appendTo support ([16d854c](https://github.com/angular-ui/bootstrap/commit/16d854c)), closes [#4599](https://github.com/angular-ui/bootstrap/issues/4599) +* **modal:** add closed promise ([e9c4977](https://github.com/angular-ui/bootstrap/commit/e9c4977)), closes [#4979](https://github.com/angular-ui/bootstrap/issues/4979) +* **modal:** add pluggable resolve support ([2635f0d](https://github.com/angular-ui/bootstrap/commit/2635f0d)), closes [#3405](https://github.com/angular-ui/bootstrap/issues/3405) [#5078](https://github.com/angular-ui/bootstrap/issues/5078) +* **modal:** allow appending outside iframe ([80df015](https://github.com/angular-ui/bootstrap/commit/80df015)), closes [#4818](https://github.com/angular-ui/bootstrap/issues/4818) +* **modal:** change to use $animate ([742132a](https://github.com/angular-ui/bootstrap/commit/742132a)), closes [#3418](https://github.com/angular-ui/bootstrap/issues/3418) [#4834](https://github.com/angular-ui/bootstrap/issues/4834) +* **modal:** remove deprecated code ([a85d499](https://github.com/angular-ui/bootstrap/commit/a85d499)), closes [#4709](https://github.com/angular-ui/bootstrap/issues/4709) +* **modal:** support requiring from parent directive ([e28cced](https://github.com/angular-ui/bootstrap/commit/e28cced)), closes [#3765](https://github.com/angular-ui/bootstrap/issues/3765) [#4844](https://github.com/angular-ui/bootstrap/issues/4844) +* **pager:** change controllerAs to pager ([5890248](https://github.com/angular-ui/bootstrap/commit/5890248)), closes [#4961](https://github.com/angular-ui/bootstrap/issues/4961) +* **pager:** move to separate component ([2a3314d](https://github.com/angular-ui/bootstrap/commit/2a3314d)), closes [#4935](https://github.com/angular-ui/bootstrap/issues/4935) +* **pagination:** add force-ellipses option and boundaryLinkNumbers ([56642ea](https://github.com/angular-ui/bootstrap/commit/56642ea)), closes [#2924](https://github.com/angular-ui/bootstrap/issues/2924) [#3064](https://github.com/angular-ui/bootstrap/issues/3064) [#3565](https://github.com/angular-ui/bootstrap/issues/3565) +* **pagination:** remove deprecated code ([75e493a](https://github.com/angular-ui/bootstrap/commit/75e493a)), closes [#4720](https://github.com/angular-ui/bootstrap/issues/4720) +* **pagination:** Show ellipsis when rotating ([3f307e4](https://github.com/angular-ui/bootstrap/commit/3f307e4)) +* **paging:** factor out common controller code ([f2f8c4e](https://github.com/angular-ui/bootstrap/commit/f2f8c4e)), closes [#4803](https://github.com/angular-ui/bootstrap/issues/4803) [#4968](https://github.com/angular-ui/bootstrap/issues/4968) +* **position:** implement auto positioning ([d265113](https://github.com/angular-ui/bootstrap/commit/d265113)) +* **position:** remove deprecated code ([42fa28f](https://github.com/angular-ui/bootstrap/commit/42fa28f)), closes [#4721](https://github.com/angular-ui/bootstrap/issues/4721) +* **progressbar:** remove deprecated code ([0669b06](https://github.com/angular-ui/bootstrap/commit/0669b06)), closes [#4722](https://github.com/angular-ui/bootstrap/issues/4722) +* **rating:** remove deprecated code ([d844623](https://github.com/angular-ui/bootstrap/commit/d844623)), closes [#4723](https://github.com/angular-ui/bootstrap/issues/4723) +* **tabs:** add controllerAs support ([a5cac90](https://github.com/angular-ui/bootstrap/commit/a5cac90)), closes [#5019](https://github.com/angular-ui/bootstrap/issues/5019) [#5020](https://github.com/angular-ui/bootstrap/issues/5020) +* **tabs:** remove deprecated code ([1b75164](https://github.com/angular-ui/bootstrap/commit/1b75164)), closes [#4710](https://github.com/angular-ui/bootstrap/issues/4710) +* **timepicker:** add model state support ([fe69386](https://github.com/angular-ui/bootstrap/commit/fe69386)), closes [#3527](https://github.com/angular-ui/bootstrap/issues/3527) [#4835](https://github.com/angular-ui/bootstrap/issues/4835) +* **timepicker:** add ngDisabled support ([4651191](https://github.com/angular-ui/bootstrap/commit/4651191)), closes [#2219](https://github.com/angular-ui/bootstrap/issues/2219) [#4811](https://github.com/angular-ui/bootstrap/issues/4811) +* **timepicker:** add semantic classes ([1a822a1](https://github.com/angular-ui/bootstrap/commit/1a822a1)), closes [#4764](https://github.com/angular-ui/bootstrap/issues/4764) [#4971](https://github.com/angular-ui/bootstrap/issues/4971) +* **timepicker:** add support for seconds ([c7fa845](https://github.com/angular-ui/bootstrap/commit/c7fa845)), closes [#4768](https://github.com/angular-ui/bootstrap/issues/4768) +* **timepicker:** added ability to handle empty model ([8ffdaeb](https://github.com/angular-ui/bootstrap/commit/8ffdaeb)), closes [#1114](https://github.com/angular-ui/bootstrap/issues/1114) [#4203](https://github.com/angular-ui/bootstrap/issues/4203) [#4617](https://github.com/angular-ui/bootstrap/issues/4617) +* **timepicker:** remove deprecated code ([feb2b73](https://github.com/angular-ui/bootstrap/commit/feb2b73)), closes [#4712](https://github.com/angular-ui/bootstrap/issues/4712) +* **tooltip:** add appendToBody only attribute support ([2a1aaf2](https://github.com/angular-ui/bootstrap/commit/2a1aaf2)), closes [#4945](https://github.com/angular-ui/bootstrap/issues/4945) [#5071](https://github.com/angular-ui/bootstrap/issues/5071) +* **tooltip:** add outsideClick trigger ([8737303](https://github.com/angular-ui/bootstrap/commit/8737303)), closes [#4419](https://github.com/angular-ui/bootstrap/issues/4419) [#4871](https://github.com/angular-ui/bootstrap/issues/4871) +* **tooltip:** change back to jqLite listeners ([a5ca78a](https://github.com/angular-ui/bootstrap/commit/a5ca78a)), closes [#4886](https://github.com/angular-ui/bootstrap/issues/4886) [#5157](https://github.com/angular-ui/bootstrap/issues/5157) +* **tooltip:** remove deprecated code ([187f64c](https://github.com/angular-ui/bootstrap/commit/187f64c)), closes [#4713](https://github.com/angular-ui/bootstrap/issues/4713) +* **typeahead:** change to `appendTo` ([8637afc](https://github.com/angular-ui/bootstrap/commit/8637afc)), closes [#4797](https://github.com/angular-ui/bootstrap/issues/4797) +* add npm support in main repository ([a9e476f](https://github.com/angular-ui/bootstrap/commit/a9e476f)), closes [#4739](https://github.com/angular-ui/bootstrap/issues/4739) [#5129](https://github.com/angular-ui/bootstrap/issues/5129) +* prefix virtual templates with `uib/` ([342c576](https://github.com/angular-ui/bootstrap/commit/342c576)), closes [#1205](https://github.com/angular-ui/bootstrap/issues/1205) [#4839](https://github.com/angular-ui/bootstrap/issues/4839) +* **typeahead:** add 'is-open' support ([167cfad](https://github.com/angular-ui/bootstrap/commit/167cfad)), closes [#4760](https://github.com/angular-ui/bootstrap/issues/4760) [#4779](https://github.com/angular-ui/bootstrap/issues/4779) +* **typeahead:** add ability to scroll with matches ([a1355e7](https://github.com/angular-ui/bootstrap/commit/a1355e7)), closes [#4463](https://github.com/angular-ui/bootstrap/issues/4463) +* **typeahead:** add dynamic toggling of Editable ([a5bafe6](https://github.com/angular-ui/bootstrap/commit/a5bafe6)), closes [#2638](https://github.com/angular-ui/bootstrap/issues/2638) [#4820](https://github.com/angular-ui/bootstrap/issues/4820) +* **typeahead:** add event object to onSelect ([3e876b8](https://github.com/angular-ui/bootstrap/commit/3e876b8)), closes [#5165](https://github.com/angular-ui/bootstrap/issues/5165) +* **typeahead:** add min-length === 0 support ([d859f42](https://github.com/angular-ui/bootstrap/commit/d859f42)), closes [#764](https://github.com/angular-ui/bootstrap/issues/764) [#2324](https://github.com/angular-ui/bootstrap/issues/2324) [#4789](https://github.com/angular-ui/bootstrap/issues/4789) +* **typeahead:** add ng-model-options debounce support ([bd47f6c](https://github.com/angular-ui/bootstrap/commit/bd47f6c)), closes [#4982](https://github.com/angular-ui/bootstrap/issues/4982) +* **typeahead:** add show-hint option ([ef82ad1](https://github.com/angular-ui/bootstrap/commit/ef82ad1)), closes [#2570](https://github.com/angular-ui/bootstrap/issues/2570) [#4784](https://github.com/angular-ui/bootstrap/issues/4784) +* **typeahead:** remove deprecated code ([606d419](https://github.com/angular-ui/bootstrap/commit/606d419)), closes [#4711](https://github.com/angular-ui/bootstrap/issues/4711) + +## Reverts + +* **dateparser:** change template literal to ' ([f40066a](https://github.com/angular-ui/bootstrap/commit/f40066a)), closes [#5091](https://github.com/angular-ui/bootstrap/issues/5091) +* **progressbar:** remove min-width ([ed7e460](https://github.com/angular-ui/bootstrap/commit/ed7e460)), closes [#5141](https://github.com/angular-ui/bootstrap/issues/5141) + + +## BREAKING CHANGES + +* all: All of the deprecated services/directives/etc. are removed - one must use all prefixed versions instead +* pager: As part of the split of the pager component from the +pagination component, this changes the controllerAs use to `pager` from +`pagination` +* dropdown: `keyboard-nav` for the dropdown is no longer a directive and to use it you have to use `keyboard-nav` instead of `uib-keyboard-nav`. +* dropdown: remove class support for `uib-dropdown-menu` directive. +* All virtual templates in UI Bootstrap now are prefixed +with `uib/` - if one is overriding the templates via `$templateCache` path +or manually building the templates from the UI Bootstrap repository, one +needs to change the string used in the `$templateCache` representation +to have the new prefix +* typeahead: Usage before +```html + + +``` +After +```html + + +``` +```js +$scope.typeaheadContainer = angular.element(document.querySelector('#typeaheadContainer')); +``` +* tab: This causes the cursor style to be removed from the tab - implement CSS on the `.uib-tab > div` selector, or similar, accordingly +* accordion: This causes the cursor style to be removed from the heading - implement CSS on the `accordion-toggle` class accordingly +* datepicker: yearRange is replaced by yearRows and yearColumns for manually specifying the dimensions of the yearpicker. If one wants the prior behavior with yearRange with a different number of rows, just set yearRows + + + + +# [0.14.3](https://github.com/angular-ui/bootstrap/compare/0.14.2...0.14.3) (2015-10-23) + + +## Bug Fixes + +* **alert:** allow interpolations with dismiss-on-timeout ([de24f46](https://github.com/angular-ui/bootstrap/commit/de24f46)), closes [#4665](https://github.com/angular-ui/bootstrap/issues/4665) [#4666](https://github.com/angular-ui/bootstrap/issues/4666) +* **buttons:** double toggle on spacebar ([e8808d3](https://github.com/angular-ui/bootstrap/commit/e8808d3)), closes [#4474](https://github.com/angular-ui/bootstrap/issues/4474) [#4630](https://github.com/angular-ui/bootstrap/issues/4630) +* **collapse:** fix collapse animation timing ([6d1cd0f](https://github.com/angular-ui/bootstrap/commit/6d1cd0f)), closes [#4493](https://github.com/angular-ui/bootstrap/issues/4493) +* **collapse:** trigger digest after ([3144633](https://github.com/angular-ui/bootstrap/commit/3144633)), closes [#4647](https://github.com/angular-ui/bootstrap/issues/4647) [#4628](https://github.com/angular-ui/bootstrap/issues/4628) [#4561](https://github.com/angular-ui/bootstrap/issues/4561) [#4651](https://github.com/angular-ui/bootstrap/issues/4651) +* **datepicker:** datepicker-popup nest in dropdown ([134086a](https://github.com/angular-ui/bootstrap/commit/134086a)), closes [#4197](https://github.com/angular-ui/bootstrap/issues/4197) [#4693](https://github.com/angular-ui/bootstrap/issues/4693) +* **datepicker:** fix support for literal format on popup ([7c3c631](https://github.com/angular-ui/bootstrap/commit/7c3c631)), closes [#4635](https://github.com/angular-ui/bootstrap/issues/4635) [#4616](https://github.com/angular-ui/bootstrap/issues/4616) +* **tooltip:** delay timeouts ([02425b8](https://github.com/angular-ui/bootstrap/commit/02425b8)), closes [#4621](https://github.com/angular-ui/bootstrap/issues/4621) [#4618](https://github.com/angular-ui/bootstrap/issues/4618) +* **tooltip:** null scope check in isOpen watch ([1f94104](https://github.com/angular-ui/bootstrap/commit/1f94104)), closes [#4697](https://github.com/angular-ui/bootstrap/issues/4697) [#4683](https://github.com/angular-ui/bootstrap/issues/4683) +* **tooltip:** scrollbar flashing ([6c82b2b](https://github.com/angular-ui/bootstrap/commit/6c82b2b)), closes [#4550](https://github.com/angular-ui/bootstrap/issues/4550) [#4623](https://github.com/angular-ui/bootstrap/issues/4623) [#4458](https://github.com/angular-ui/bootstrap/issues/4458) +* **typeahead:** dangling event listeners ([94fb282](https://github.com/angular-ui/bootstrap/commit/94fb282)), closes [#4632](https://github.com/angular-ui/bootstrap/issues/4632) [#4636](https://github.com/angular-ui/bootstrap/issues/4636) + +## Features + +* **datepicker:** add templateUrl support for pickers ([1f65d87](https://github.com/angular-ui/bootstrap/commit/1f65d87)), closes [#4432](https://github.com/angular-ui/bootstrap/issues/4432) +* **datepicker:** preserve timezone with model ([0d64aad](https://github.com/angular-ui/bootstrap/commit/0d64aad)), closes [#4676](https://github.com/angular-ui/bootstrap/issues/4676) +* **modal:** support $uibModalInstance ([97fd37e](https://github.com/angular-ui/bootstrap/commit/97fd37e)), closes [#4638](https://github.com/angular-ui/bootstrap/issues/4638) [#4661](https://github.com/angular-ui/bootstrap/issues/4661) + + + + +# [0.14.2](https://github.com/angular-ui/bootstrap/compare/0.14.1...0.14.2) (2015-10-14) + + +## Bug Fixes + +* **progressbar:** fix percentage calculation ([feb689c](https://github.com/angular-ui/bootstrap/commit/feb689c)), closes [#4471](https://github.com/angular-ui/bootstrap/issues/4471) [#4588](https://github.com/angular-ui/bootstrap/issues/4588) [#4452](https://github.com/angular-ui/bootstrap/issues/4452) +* **tooltip:** clean up stackedMap on scope destroy ([ebb5e18](https://github.com/angular-ui/bootstrap/commit/ebb5e18)), closes [#4610](https://github.com/angular-ui/bootstrap/issues/4610) [#4604](https://github.com/angular-ui/bootstrap/issues/4604) +* **tooltip:** popup close delay not respected ([6daf871](https://github.com/angular-ui/bootstrap/commit/6daf871)), closes [#4597](https://github.com/angular-ui/bootstrap/issues/4597) [#4567](https://github.com/angular-ui/bootstrap/issues/4567) + + + + +# [0.14.1](https://github.com/angular-ui/bootstrap/compare/0.14.0...0.14.1) (2015-10-11) + + +## Bug Fixes + +* **accordion:** make deprecated controller work with 1.3.x ([c5e6042](https://github.com/angular-ui/bootstrap/commit/c5e6042)), closes [#4574](https://github.com/angular-ui/bootstrap/issues/4574) +* **alert:** make deprecated controller work with 1.3.x ([e8c8ee6](https://github.com/angular-ui/bootstrap/commit/e8c8ee6)), closes [#4576](https://github.com/angular-ui/bootstrap/issues/4576) +* **buttons:** make deprecated controller work with 1.3.x ([1e3cbd8](https://github.com/angular-ui/bootstrap/commit/1e3cbd8)), closes [#4577](https://github.com/angular-ui/bootstrap/issues/4577) +* **carousel:** make deprecated controller work with 1.3.x ([f6c7931](https://github.com/angular-ui/bootstrap/commit/f6c7931)), closes [#4578](https://github.com/angular-ui/bootstrap/issues/4578) +* **datepicker:** make deprecated controller work with 1.3.x ([18371ab](https://github.com/angular-ui/bootstrap/commit/18371ab)), closes [#4586](https://github.com/angular-ui/bootstrap/issues/4586) +* **dropdown:** make deprecated controller work with 1.3.x ([ae1a87c](https://github.com/angular-ui/bootstrap/commit/ae1a87c)), closes [#4585](https://github.com/angular-ui/bootstrap/issues/4585) +* **pagination:** make deprecated controller work with 1.3.x ([d50e8d2](https://github.com/angular-ui/bootstrap/commit/d50e8d2)), closes [#4580](https://github.com/angular-ui/bootstrap/issues/4580) +* **progressbar:** make deprecated controller work with 1.3.x ([1c5e479](https://github.com/angular-ui/bootstrap/commit/1c5e479)), closes [#4581](https://github.com/angular-ui/bootstrap/issues/4581) +* **rating:** make deprecated controller work with 1.3.x ([ce1114a](https://github.com/angular-ui/bootstrap/commit/ce1114a)), closes [#4582](https://github.com/angular-ui/bootstrap/issues/4582) +* **tabs:** make deprecated controller work with 1.3.x ([685bd6a](https://github.com/angular-ui/bootstrap/commit/685bd6a)), closes [#4583](https://github.com/angular-ui/bootstrap/issues/4583) +* **timepicker:** make deprecated controller work with 1.3.x ([00f60ee](https://github.com/angular-ui/bootstrap/commit/00f60ee)), closes [#4584](https://github.com/angular-ui/bootstrap/issues/4584) + +## Features + +* **timepicker:** add accessibility improvements ([4ebecbc](https://github.com/angular-ui/bootstrap/commit/4ebecbc)), closes [#4569](https://github.com/angular-ui/bootstrap/issues/4569) [#4573](https://github.com/angular-ui/bootstrap/issues/4573) + + + + +# [0.14.0](https://github.com/angular-ui/bootstrap/compare/0.13.4...0.14.0) (2015-10-09) + + +## Bug Fixes + +* **accordion:** coerce to boolean ([b864aa9](https://github.com/angular-ui/bootstrap/commit/b864aa9)), closes [#4385](https://github.com/angular-ui/bootstrap/issues/4385) +* **accordion:** re-expose AccordionController ([5382226](https://github.com/angular-ui/bootstrap/commit/5382226)), closes [#4524](https://github.com/angular-ui/bootstrap/issues/4524) +* **alert:** properly pass $event as local ([eb2366f](https://github.com/angular-ui/bootstrap/commit/eb2366f)), closes [#4386](https://github.com/angular-ui/bootstrap/issues/4386) [#4387](https://github.com/angular-ui/bootstrap/issues/4387) +* **alert:** re-expose AlertController ([f561aa9](https://github.com/angular-ui/bootstrap/commit/f561aa9)), closes [#4525](https://github.com/angular-ui/bootstrap/issues/4525) +* **buttons:** re-expose ButtonsController ([c0dbf79](https://github.com/angular-ui/bootstrap/commit/c0dbf79)), closes [#4526](https://github.com/angular-ui/bootstrap/issues/4526) +* **carousel:** fix reading of `noTransition` ([2e26815](https://github.com/angular-ui/bootstrap/commit/2e26815)), closes [#4325](https://github.com/angular-ui/bootstrap/issues/4325) +* **carousel:** improve accessibility ([da71159](https://github.com/angular-ui/bootstrap/commit/da71159)), closes [#4478](https://github.com/angular-ui/bootstrap/issues/4478) [#4479](https://github.com/angular-ui/bootstrap/issues/4479) +* **carousel:** re-enable deprecated directives ([30e8aa7](https://github.com/angular-ui/bootstrap/commit/30e8aa7)), closes [#4527](https://github.com/angular-ui/bootstrap/issues/4527) +* **carousel:** reset $currentTransition when no slides ([0b3d5bd](https://github.com/angular-ui/bootstrap/commit/0b3d5bd)), closes [#4532](https://github.com/angular-ui/bootstrap/issues/4532) [#4390](https://github.com/angular-ui/bootstrap/issues/4390) +* **datepicker:** add check for `contains` ([868c0e2](https://github.com/angular-ui/bootstrap/commit/868c0e2)), closes [#4423](https://github.com/angular-ui/bootstrap/issues/4423) [#4411](https://github.com/angular-ui/bootstrap/issues/4411) +* **datepicker:** add custom class to year picker ([0ad7cb9](https://github.com/angular-ui/bootstrap/commit/0ad7cb9)), closes [#4558](https://github.com/angular-ui/bootstrap/issues/4558) [#4546](https://github.com/angular-ui/bootstrap/issues/4546) +* **datepicker:** change to `$popup` ([65814f1](https://github.com/angular-ui/bootstrap/commit/65814f1)) +* **datepicker:** datepicker-popup nest in dropdown ([6b4267b](https://github.com/angular-ui/bootstrap/commit/6b4267b)), closes [#4489](https://github.com/angular-ui/bootstrap/issues/4489) [#4197](https://github.com/angular-ui/bootstrap/issues/4197) +* **datepicker:** remove focus management on date selection by keyboard ([36ecf60](https://github.com/angular-ui/bootstrap/commit/36ecf60)), closes [#4409](https://github.com/angular-ui/bootstrap/issues/4409) +* **dropdown:** ensure class is present in dropdown-menu ([92ab48e](https://github.com/angular-ui/bootstrap/commit/92ab48e)), closes [#4523](https://github.com/angular-ui/bootstrap/issues/4523) [#4442](https://github.com/angular-ui/bootstrap/issues/4442) +* **dropdown:** restore deprecated directives ([e7c5879](https://github.com/angular-ui/bootstrap/commit/e7c5879)), closes [#4514](https://github.com/angular-ui/bootstrap/issues/4514) +* **modal:** fix for conflicts with ngTouch module on mobile devices ([508aceb](https://github.com/angular-ui/bootstrap/commit/508aceb)), closes [#2280](https://github.com/angular-ui/bootstrap/issues/2280) [#4357](https://github.com/angular-ui/bootstrap/issues/4357) +* **progressbar:** re-expose ProgressController ([5604e59](https://github.com/angular-ui/bootstrap/commit/5604e59)), closes [#4528](https://github.com/angular-ui/bootstrap/issues/4528) +* **rating:** re-expose RatingController ([aede646](https://github.com/angular-ui/bootstrap/commit/aede646)), closes [#4529](https://github.com/angular-ui/bootstrap/issues/4529) +* **tabs:** re-expose TabsetController ([435924f](https://github.com/angular-ui/bootstrap/commit/435924f)), closes [#4530](https://github.com/angular-ui/bootstrap/issues/4530) +* **timepicker:** re-expose TimepickerController ([3aa9841](https://github.com/angular-ui/bootstrap/commit/3aa9841)), closes [#4531](https://github.com/angular-ui/bootstrap/issues/4531) +* **tooltip:** add display block to style ([b413a22](https://github.com/angular-ui/bootstrap/commit/b413a22)), closes [#4363](https://github.com/angular-ui/bootstrap/issues/4363) [#4379](https://github.com/angular-ui/bootstrap/issues/4379) +* **tooltip:** check for ttScope in $$postDigest ([01b9624](https://github.com/angular-ui/bootstrap/commit/01b9624)), closes [#4555](https://github.com/angular-ui/bootstrap/issues/4555) [#4552](https://github.com/angular-ui/bootstrap/issues/4552) +* **tooltip:** correct flash of reposition ([8fee75d](https://github.com/angular-ui/bootstrap/commit/8fee75d)), closes [#4363](https://github.com/angular-ui/bootstrap/issues/4363) [#4195](https://github.com/angular-ui/bootstrap/issues/4195) +* **tooltip:** do nothing if `$scope` doesn't exist ([1e039e8](https://github.com/angular-ui/bootstrap/commit/1e039e8)), closes [#4346](https://github.com/angular-ui/bootstrap/issues/4346) [#3347](https://github.com/angular-ui/bootstrap/issues/3347) +* **tooltip:** fix binding to multiple triggers ([d6cda93](https://github.com/angular-ui/bootstrap/commit/d6cda93)), closes [#4371](https://github.com/angular-ui/bootstrap/issues/4371) [#4384](https://github.com/angular-ui/bootstrap/issues/4384) +* **tooltip:** isOpen to work with expressions ([5f68280](https://github.com/angular-ui/bootstrap/commit/5f68280)), closes [#4380](https://github.com/angular-ui/bootstrap/issues/4380) [#4362](https://github.com/angular-ui/bootstrap/issues/4362) +* **tooltip:** properly gc popupTimeout ([ff52f52](https://github.com/angular-ui/bootstrap/commit/ff52f52)), closes [#2786](https://github.com/angular-ui/bootstrap/issues/2786) +* **tooltip:** set `visibility: hidden` to avoid flicker ([f7cb8bc](https://github.com/angular-ui/bootstrap/commit/f7cb8bc)), closes [#4342](https://github.com/angular-ui/bootstrap/issues/4342) + +## Features + +* **accordion:** use uib- prefix ([0328a76](https://github.com/angular-ui/bootstrap/commit/0328a76)), closes [#4389](https://github.com/angular-ui/bootstrap/issues/4389) +* **accordion:** use uib- prefix ([298ec8c](https://github.com/angular-ui/bootstrap/commit/298ec8c)), closes [#4503](https://github.com/angular-ui/bootstrap/issues/4503) +* **alert:** use uib- prefix ([5e3a87a](https://github.com/angular-ui/bootstrap/commit/5e3a87a)), closes [#4406](https://github.com/angular-ui/bootstrap/issues/4406) +* **buttons:** use uib- prefix ([5a1c2c9](https://github.com/angular-ui/bootstrap/commit/5a1c2c9)), closes [#4445](https://github.com/angular-ui/bootstrap/issues/4445) +* **carousel:** use uib- prefix ([2e5bfac](https://github.com/angular-ui/bootstrap/commit/2e5bfac)), closes [#4501](https://github.com/angular-ui/bootstrap/issues/4501) +* **collapse:** convert to use `$animateCss` ([533a9f0](https://github.com/angular-ui/bootstrap/commit/533a9f0)), closes [#4257](https://github.com/angular-ui/bootstrap/issues/4257) +* **collapse:** use uib- prefix ([9bdb32e](https://github.com/angular-ui/bootstrap/commit/9bdb32e)), closes [#4370](https://github.com/angular-ui/bootstrap/issues/4370) +* **dateparser:** reset parsers when $locale.id changes ([d9a521a](https://github.com/angular-ui/bootstrap/commit/d9a521a)), closes [#4286](https://github.com/angular-ui/bootstrap/issues/4286) [#4425](https://github.com/angular-ui/bootstrap/issues/4425) +* **dateparser:** use uib- prefix ([0fa851f](https://github.com/angular-ui/bootstrap/commit/0fa851f)), closes [#4504](https://github.com/angular-ui/bootstrap/issues/4504) +* **datepicker:** add uib- prefix ([44354f6](https://github.com/angular-ui/bootstrap/commit/44354f6)), closes [#4509](https://github.com/angular-ui/bootstrap/issues/4509) +* **dropdown:** uib- prefix ([5bc0851](https://github.com/angular-ui/bootstrap/commit/5bc0851)), closes [#4510](https://github.com/angular-ui/bootstrap/issues/4510) +* **modal:** Added ability to add CSS class to top window ([bd38e8f](https://github.com/angular-ui/bootstrap/commit/bd38e8f)), closes [#2524](https://github.com/angular-ui/bootstrap/issues/2524) +* **modal:** add uib- prefix ([8c7b9e4](https://github.com/angular-ui/bootstrap/commit/8c7b9e4)), closes [#4511](https://github.com/angular-ui/bootstrap/issues/4511) +* **pagination:** add uib- prefix ([9aea856](https://github.com/angular-ui/bootstrap/commit/9aea856)), closes [#4536](https://github.com/angular-ui/bootstrap/issues/4536) +* **position:** add uib- prefix ([6158091](https://github.com/angular-ui/bootstrap/commit/6158091)), closes [#4507](https://github.com/angular-ui/bootstrap/issues/4507) +* **progressbar:** add `aria-labelledby` support ([e6f3b87](https://github.com/angular-ui/bootstrap/commit/e6f3b87)), closes [#4350](https://github.com/angular-ui/bootstrap/issues/4350) [#4347](https://github.com/angular-ui/bootstrap/issues/4347) +* **rating:** add `aria-valuetext` attribute ([72de2d8](https://github.com/angular-ui/bootstrap/commit/72de2d8)), closes [#4349](https://github.com/angular-ui/bootstrap/issues/4349) [#4347](https://github.com/angular-ui/bootstrap/issues/4347) +* **rating:** user uib- prefix ([377b4b7](https://github.com/angular-ui/bootstrap/commit/377b4b7)), closes [#4502](https://github.com/angular-ui/bootstrap/issues/4502) +* **tabs:** use uib- prefix ([d25a8c2](https://github.com/angular-ui/bootstrap/commit/d25a8c2)), closes [#4449](https://github.com/angular-ui/bootstrap/issues/4449) +* **timepicker:** use uib- prefix ([504e653](https://github.com/angular-ui/bootstrap/commit/504e653)), closes [#4505](https://github.com/angular-ui/bootstrap/issues/4505) +* **tooltip:** add uib- prefix ([f8bc038](https://github.com/angular-ui/bootstrap/commit/f8bc038)), closes [#4515](https://github.com/angular-ui/bootstrap/issues/4515) +* **tooltip:** allow custom closing delay ([5f7051b](https://github.com/angular-ui/bootstrap/commit/5f7051b)), closes [#3576](https://github.com/angular-ui/bootstrap/issues/3576) +* **tooltip:** hide tooltip when `esc` is hit ([c08509a](https://github.com/angular-ui/bootstrap/commit/c08509a)), closes [#4367](https://github.com/angular-ui/bootstrap/issues/4367) [#4248](https://github.com/angular-ui/bootstrap/issues/4248) +* **typeahead:** add `appendElementToId` ([fdf53e6](https://github.com/angular-ui/bootstrap/commit/fdf53e6)), closes [#4231](https://github.com/angular-ui/bootstrap/issues/4231) [#4497](https://github.com/angular-ui/bootstrap/issues/4497) +* **typeahead:** add customClass support for dropdown ([fa1cdfc](https://github.com/angular-ui/bootstrap/commit/fa1cdfc)), closes [#4332](https://github.com/angular-ui/bootstrap/issues/4332) [#4410](https://github.com/angular-ui/bootstrap/issues/4410) +* **typeahead:** add uib- prefix ([9e5e1a2](https://github.com/angular-ui/bootstrap/commit/9e5e1a2)), closes [#4542](https://github.com/angular-ui/bootstrap/issues/4542) + +## Reverts + +* **dropdown:** undo adding of `open` class on body ([6f9f1fc](https://github.com/angular-ui/bootstrap/commit/6f9f1fc)) + + +## BREAKING CHANGES + +* Removes focus on datepicker on selection of a date via keyboard for accessibility reasons + + + +# [0.13.4](https://github.com/angular-ui/bootstrap/compare/0.13.3...0.13.4) (2015-09-03) + + +## Bug Fixes + +* **accordion:** add custom open class support ([575eb85](https://github.com/angular-ui/bootstrap/commit/575eb85)), closes [#4198](https://github.com/angular-ui/bootstrap/issues/4198) +* **datepicker:** ensure the original target is not in popup ([9b2f7ac](https://github.com/angular-ui/bootstrap/commit/9b2f7ac)), closes [#4316](https://github.com/angular-ui/bootstrap/issues/4316) [#4314](https://github.com/angular-ui/bootstrap/issues/4314) +* **dropdown:** fix display when using with append-to-body ([bf63cef](https://github.com/angular-ui/bootstrap/commit/bf63cef)), closes [#4305](https://github.com/angular-ui/bootstrap/issues/4305) [#4240](https://github.com/angular-ui/bootstrap/issues/4240) +* **dropdown:** fix up arrow nav support ([defcbbb](https://github.com/angular-ui/bootstrap/commit/defcbbb)), closes [#4330](https://github.com/angular-ui/bootstrap/issues/4330) [#4327](https://github.com/angular-ui/bootstrap/issues/4327) +* **modal:** Wait for animation before focus. ([937a1f3](https://github.com/angular-ui/bootstrap/commit/937a1f3)), closes [#4300](https://github.com/angular-ui/bootstrap/issues/4300) [#4274](https://github.com/angular-ui/bootstrap/issues/4274) +* **modal:** correctly remove custom class ([ba2ce24](https://github.com/angular-ui/bootstrap/commit/ba2ce24)), closes [#4175](https://github.com/angular-ui/bootstrap/issues/4175) [#4171](https://github.com/angular-ui/bootstrap/issues/4171) +* **modal:** fix allowing promises to be resolved ([b1e98b1](https://github.com/angular-ui/bootstrap/commit/b1e98b1)), closes [#4310](https://github.com/angular-ui/bootstrap/issues/4310) [#4309](https://github.com/angular-ui/bootstrap/issues/4309) +* **progress:** rename to avoid conflict ([07a938d](https://github.com/angular-ui/bootstrap/commit/07a938d)), closes [#4255](https://github.com/angular-ui/bootstrap/issues/4255) +* **tabs:** ensure tab selection only occurs once ([7d3ba1e](https://github.com/angular-ui/bootstrap/commit/7d3ba1e)), closes [#3060](https://github.com/angular-ui/bootstrap/issues/3060) [#4230](https://github.com/angular-ui/bootstrap/issues/4230) [#2883](https://github.com/angular-ui/bootstrap/issues/2883) +* **timepicker:** leave view alone if either input is invalid ([818f7e5](https://github.com/angular-ui/bootstrap/commit/818f7e5)), closes [#4160](https://github.com/angular-ui/bootstrap/issues/4160) [#3825](https://github.com/angular-ui/bootstrap/issues/3825) +* **tooltip:** correctly position tooltip ([457f10c](https://github.com/angular-ui/bootstrap/commit/457f10c)), closes [#4311](https://github.com/angular-ui/bootstrap/issues/4311) [#4195](https://github.com/angular-ui/bootstrap/issues/4195) +* **tooltip:** fix jshint error ([17cc39f](https://github.com/angular-ui/bootstrap/commit/17cc39f)) +* **tooltip:** properly gc timeout on toggle of disabled ([f8eab55](https://github.com/angular-ui/bootstrap/commit/f8eab55)), closes [#4210](https://github.com/angular-ui/bootstrap/issues/4210) [#4204](https://github.com/angular-ui/bootstrap/issues/4204) +* **tooltip:** switch to use raw DOM event bindings ([7556bed](https://github.com/angular-ui/bootstrap/commit/7556bed)), closes [#4322](https://github.com/angular-ui/bootstrap/issues/4322) [#4060](https://github.com/angular-ui/bootstrap/issues/4060) +* **typeahead:** add support for ngModelOptions getterSetter ([ccaa627](https://github.com/angular-ui/bootstrap/commit/ccaa627)), closes [#3865](https://github.com/angular-ui/bootstrap/issues/3865) [#3823](https://github.com/angular-ui/bootstrap/issues/3823) +* **typeahead:** release references on destruction ([695db9d](https://github.com/angular-ui/bootstrap/commit/695db9d)), closes [#4299](https://github.com/angular-ui/bootstrap/issues/4299) [#4298](https://github.com/angular-ui/bootstrap/issues/4298) +* **typeahead:** use ng-bind-html ([bb9fa1a](https://github.com/angular-ui/bootstrap/commit/bb9fa1a)), closes [#3463](https://github.com/angular-ui/bootstrap/issues/3463) [#4073](https://github.com/angular-ui/bootstrap/issues/4073) + +## Features + +* **accordion:** allow custom panel class ([5ee23a4](https://github.com/angular-ui/bootstrap/commit/5ee23a4)), closes [#4242](https://github.com/angular-ui/bootstrap/issues/4242) [#3968](https://github.com/angular-ui/bootstrap/issues/3968) +* **accordion:** support spacebar to toggle group ([aa5a991](https://github.com/angular-ui/bootstrap/commit/aa5a991)), closes [#4319](https://github.com/angular-ui/bootstrap/issues/4319) [#4249](https://github.com/angular-ui/bootstrap/issues/4249) +* **buttons:** allow toggling via spacebar when focused ([bdfb289](https://github.com/angular-ui/bootstrap/commit/bdfb289)), closes [#4252](https://github.com/angular-ui/bootstrap/issues/4252) [#4259](https://github.com/angular-ui/bootstrap/issues/4259) +* **buttons:** hide nested inputs ([a06afe6](https://github.com/angular-ui/bootstrap/commit/a06afe6)), closes [#4282](https://github.com/angular-ui/bootstrap/issues/4282) +* **carousel:** add model binding support to slide ([dac087e](https://github.com/angular-ui/bootstrap/commit/dac087e)), closes [#4202](https://github.com/angular-ui/bootstrap/issues/4202) [#4201](https://github.com/angular-ui/bootstrap/issues/4201) +* **dateparser:** add support for the `h` format ([550fe20](https://github.com/angular-ui/bootstrap/commit/550fe20)), closes [#4220](https://github.com/angular-ui/bootstrap/issues/4220) +* **datepicker:** disable today button if invalid ([71e0b8a](https://github.com/angular-ui/bootstrap/commit/71e0b8a)), closes [#4199](https://github.com/angular-ui/bootstrap/issues/4199) [#3988](https://github.com/angular-ui/bootstrap/issues/3988) +* **modal:** complete modal open resolution in order ([1bba8b4](https://github.com/angular-ui/bootstrap/commit/1bba8b4)), closes [#2443](https://github.com/angular-ui/bootstrap/issues/2443) [#4302](https://github.com/angular-ui/bootstrap/issues/4302) [#2404](https://github.com/angular-ui/bootstrap/issues/2404) +* **modal:** support multiple open classes ([3d01c59](https://github.com/angular-ui/bootstrap/commit/3d01c59)), closes [#4226](https://github.com/angular-ui/bootstrap/issues/4226) [#4184](https://github.com/angular-ui/bootstrap/issues/4184) +* **pagination:** add `ngDisabled` support for the pager ([ba734b4](https://github.com/angular-ui/bootstrap/commit/ba734b4)), closes [#4217](https://github.com/angular-ui/bootstrap/issues/4217) [#2856](https://github.com/angular-ui/bootstrap/issues/2856) +* **pagination:** add `templateUrl` support ([64b5289](https://github.com/angular-ui/bootstrap/commit/64b5289)), closes [#4162](https://github.com/angular-ui/bootstrap/issues/4162) +* **tabs:** add support for `x-tab-heading` ([1abfd05](https://github.com/angular-ui/bootstrap/commit/1abfd05)), closes [#4166](https://github.com/angular-ui/bootstrap/issues/4166) [#1893](https://github.com/angular-ui/bootstrap/issues/1893) +* **timepicker:** add `templateUrl` and `controllerAs` support ([639d511](https://github.com/angular-ui/bootstrap/commit/639d511)), closes [#4275](https://github.com/angular-ui/bootstrap/issues/4275) [#4284](https://github.com/angular-ui/bootstrap/issues/4284) +* **tooltip:** expose isOpen property ([99b87cc](https://github.com/angular-ui/bootstrap/commit/99b87cc)), closes [#4179](https://github.com/angular-ui/bootstrap/issues/4179) [#2148](https://github.com/angular-ui/bootstrap/issues/2148) [#590](https://github.com/angular-ui/bootstrap/issues/590) +* **typeahead:** add `typeaheadFocusOnSelect` ([b5ecda3](https://github.com/angular-ui/bootstrap/commit/b5ecda3)), closes [#4212](https://github.com/angular-ui/bootstrap/issues/4212) [#4211](https://github.com/angular-ui/bootstrap/issues/4211) [#4206](https://github.com/angular-ui/bootstrap/issues/4206) +* **typeahead:** add custom popup template support ([4b02648](https://github.com/angular-ui/bootstrap/commit/4b02648)), closes [#4320](https://github.com/angular-ui/bootstrap/issues/4320) [#3774](https://github.com/angular-ui/bootstrap/issues/3774) + + +## Breaking Changes + +* **buttons** + * hide nested `` elements on `btn-radio` and `btn-checkbox` directives. + + Fixes #3264 + Closes #4282 + + ([a06afe6](https://github.com/angular-ui/bootstrap/commit/a06afe6)) + +* **dropdown** + * when using `append-to-body`, both the `dropdown` and `open` classes are added to the `` element. + * this differs from the existing behavior in that it will no longer toggle based on the existing `dropdown` directive element, but on the `body` element instead. + + Fixes #4240 + Closes #4305 + + ([bf63cef](https://github.com/angular-ui/bootstrap/commit/bf63cef)) + +* **tooltip** + * Switch to use `addEventListener` and `removeEventListener` to prevent jqLite/jQuery bug where the events are swallowed on disabled elements + * this affects custom events, which must now be dispatched with `element[0].dispatchEvent(new Event('customEvent'))`, as opposed to `element.trigger('customEvent')` + + Fixes #4060 + Closes #4322 + + ([7556bed](https://github.com/angular-ui/bootstrap/commit/7556beda486f26b40fb860448316e8a32457e9e9)) + +* **typeahead** + * for security reasons, only whitelisted HTML should be added. + * the typeahead match template now uses `ng-bind-html` instead of `bind-html-unsafe`. + * typeahead now uses the `$sce` service when `ngSanitize` is present and logs a warning if it is not. + + Fixes #2884 + Closes #3463 + Closes #4073 + + ([bb9fa1a](https://github.com/angular-ui/bootstrap/commit/bb9fa1a)) + + + +# [0.13.3](https://github.com/angular-ui/bootstrap/compare/0.13.2...0.13.3) (2015-08-09) + + +## Bug Fixes + +* **accordion:** + * add `open` class when expanded ([ead15e37](https://github.com/angular-ui/bootstrap/commit/ead15e37), closes [#4152](https://github.com/angular-ui/bootstrap/issues/4152), [#3419](https://github.com/angular-ui/bootstrap/issues/3419)) + * revert to empty href ([b18dc8f9](https://github.com/angular-ui/bootstrap/commit/b18dc8f9), closes [#4104](https://github.com/angular-ui/bootstrap/issues/4104)) +* **buttons:** + * change to use `attrs.disabled` ([c9b0d0b0](https://github.com/angular-ui/bootstrap/commit/c9b0d0b0), closes [#4088](https://github.com/angular-ui/bootstrap/issues/4088)) + * allow selection of undisabled button ([707fbf55](https://github.com/angular-ui/bootstrap/commit/707fbf55), closes [#4088](https://github.com/angular-ui/bootstrap/issues/4088)) +* **carousel:** + * fix animation direction ([8359d73f](https://github.com/angular-ui/bootstrap/commit/8359d73f), closes [#4092](https://github.com/angular-ui/bootstrap/issues/4092), [#4087](https://github.com/angular-ui/bootstrap/issues/4087)) + * fix sorting of indicators ([8056368e](https://github.com/angular-ui/bootstrap/commit/8056368e), closes [#4071](https://github.com/angular-ui/bootstrap/issues/4071), [#3764](https://github.com/angular-ui/bootstrap/issues/3764)) +* **dateparser:** Support 12-hour format and AM/PM ([1ecd82ce](https://github.com/angular-ui/bootstrap/commit/1ecd82ce), closes [#4117](https://github.com/angular-ui/bootstrap/issues/4117)) +* **datepicker:** + * commit safe apply on destruction ([74a8be4c](https://github.com/angular-ui/bootstrap/commit/74a8be4c), closes [#4079](https://github.com/angular-ui/bootstrap/issues/4079), [#4076](https://github.com/angular-ui/bootstrap/issues/4076)) + * change to `dateDisabled` ([5245ccad](https://github.com/angular-ui/bootstrap/commit/5245ccad), closes [#2773](https://github.com/angular-ui/bootstrap/issues/2773), [#4080](https://github.com/angular-ui/bootstrap/issues/4080)) +* **dropdown:** handle `keyboard-nav` correctly ([0b37f088](https://github.com/angular-ui/bootstrap/commit/0b37f088), closes [#4110](https://github.com/angular-ui/bootstrap/issues/4110), [#4091](https://github.com/angular-ui/bootstrap/issues/4091)) +* **modal:** + * skipping ESC handling for form inputs ([a05b9c1a](https://github.com/angular-ui/bootstrap/commit/a05b9c1a), closes [#3551](https://github.com/angular-ui/bootstrap/issues/3551), [#2544](https://github.com/angular-ui/bootstrap/issues/2544)) + * add `$animateCss` support ([c7f19d58](https://github.com/angular-ui/bootstrap/commit/c7f19d58), closes [#4121](https://github.com/angular-ui/bootstrap/issues/4121), [#4119](https://github.com/angular-ui/bootstrap/issues/4119)) + * fix test ([e60c3ff6](https://github.com/angular-ui/bootstrap/commit/e60c3ff6)) + * dismiss modal on unschedule destruction ([3584061f](https://github.com/angular-ui/bootstrap/commit/3584061f), closes [#4097](https://github.com/angular-ui/bootstrap/issues/4097), [#3694](https://github.com/angular-ui/bootstrap/issues/3694)) +* **progressbar:** fix `min-width` for Bootstrap 3.2 ([8dc13be9](https://github.com/angular-ui/bootstrap/commit/8dc13be9), closes [#4081](https://github.com/angular-ui/bootstrap/issues/4081), [#2511](https://github.com/angular-ui/bootstrap/issues/2511)) +* **tooltip:** + * add safety to `$apply` ([22b16f01](https://github.com/angular-ui/bootstrap/commit/22b16f01), closes [#3943](https://github.com/angular-ui/bootstrap/issues/3943), [#4150](https://github.com/angular-ui/bootstrap/issues/4150), [#516](https://github.com/angular-ui/bootstrap/issues/516)) + * tooltip w/ template position ([895a2281](https://github.com/angular-ui/bootstrap/commit/895a2281)) + * prevent opening when `tooltipPopupDelay` is present ([12c527af](https://github.com/angular-ui/bootstrap/commit/12c527af), closes [#4098](https://github.com/angular-ui/bootstrap/issues/4098), [#3611](https://github.com/angular-ui/bootstrap/issues/3611)) +* **typeahead:** return `null` if empty ([c7d3a660](https://github.com/angular-ui/bootstrap/commit/c7d3a660), closes [#4078](https://github.com/angular-ui/bootstrap/issues/4078), [#3176](https://github.com/angular-ui/bootstrap/issues/3176)) + + +## Features + +* **accordion:** + * add `controllerAs` support ([9865ee8e](https://github.com/angular-ui/bootstrap/commit/9865ee8e), closes [#4138](https://github.com/angular-ui/bootstrap/issues/4138)) + * add `templateUrl` support ([f777c320](https://github.com/angular-ui/bootstrap/commit/f777c320), closes [#4084](https://github.com/angular-ui/bootstrap/issues/4084)) +* **alert:** add `templateUrl` support ([88a885ca](https://github.com/angular-ui/bootstrap/commit/88a885ca), closes [#4139](https://github.com/angular-ui/bootstrap/issues/4139)) +* **buttons:** add `controllerAs` support ([02872dc1](https://github.com/angular-ui/bootstrap/commit/02872dc1), closes [#4140](https://github.com/angular-ui/bootstrap/issues/4140)) +* **carousel:** + * add `templateUrl` support ([a29c8f20](https://github.com/angular-ui/bootstrap/commit/a29c8f20), closes [#4141](https://github.com/angular-ui/bootstrap/issues/4141)) + * expose carousel controller via `controllerAs` ([bfec07e4](https://github.com/angular-ui/bootstrap/commit/bfec07e4), closes [#4131](https://github.com/angular-ui/bootstrap/issues/4131)) +* **datepicker:** + * allow custom templates ([e04b06d7](https://github.com/angular-ui/bootstrap/commit/e04b06d7), closes [#4157](https://github.com/angular-ui/bootstrap/issues/4157), [#1913](https://github.com/angular-ui/bootstrap/issues/1913)) + * add `onOpenFocus` support ([68afc4c6](https://github.com/angular-ui/bootstrap/commit/68afc4c6), closes [#2303](https://github.com/angular-ui/bootstrap/issues/2303), [#2546](https://github.com/angular-ui/bootstrap/issues/2546), [#4146](https://github.com/angular-ui/bootstrap/issues/4146)) + * add support for dynamic `min-mode` and `max-mode` ([f3d263e1](https://github.com/angular-ui/bootstrap/commit/f3d263e1), closes [#3843](https://github.com/angular-ui/bootstrap/issues/3843), [#2618](https://github.com/angular-ui/bootstrap/issues/2618)) + * allow suppression of log error ([bab1d375](https://github.com/angular-ui/bootstrap/commit/bab1d375), closes [#3836](https://github.com/angular-ui/bootstrap/issues/3836), [#4115](https://github.com/angular-ui/bootstrap/issues/4115)) +* **docs:** + * add explanation of eye icon ([265d429b](https://github.com/angular-ui/bootstrap/commit/265d429b), closes [#4120](https://github.com/angular-ui/bootstrap/issues/4120)) + * add ngAnimate Plunker support ([a8a22cff](https://github.com/angular-ui/bootstrap/commit/a8a22cff), closes [#3648](https://github.com/angular-ui/bootstrap/issues/3648), [#4072](https://github.com/angular-ui/bootstrap/issues/4072)) +* **modal:** + * add ability to change class on body ([5a28ff76](https://github.com/angular-ui/bootstrap/commit/5a28ff76), closes [#2633](https://github.com/angular-ui/bootstrap/issues/2633), [#4132](https://github.com/angular-ui/bootstrap/issues/4132)) + * allow users to resolve with strings ([89368856](https://github.com/angular-ui/bootstrap/commit/89368856), closes [#2676](https://github.com/angular-ui/bootstrap/issues/2676), [#4124](https://github.com/angular-ui/bootstrap/issues/4124)) +* **pagination:** + * add classes to assist with styling ([b21c9abd](https://github.com/angular-ui/bootstrap/commit/b21c9abd), closes [#4130](https://github.com/angular-ui/bootstrap/issues/4130), [#4142](https://github.com/angular-ui/bootstrap/issues/4142)) + * add `templateUrl` support ([a0e1c91c](https://github.com/angular-ui/bootstrap/commit/a0e1c91c), closes [#4137](https://github.com/angular-ui/bootstrap/issues/4137)) +* **timepicker:** + * add documentation for max/min ([87fc242d](https://github.com/angular-ui/bootstrap/commit/87fc242d)) + * Added min/max attributes for timepicker. ([6c0010be](https://github.com/angular-ui/bootstrap/commit/6c0010be), closes [#4019](https://github.com/angular-ui/bootstrap/issues/4019)) +* **tooltip:** + * remove unnecessary `$digest` ([901a7c66](https://github.com/angular-ui/bootstrap/commit/901a7c66), closes [#4151](https://github.com/angular-ui/bootstrap/issues/4151)) + * add multiple trigger support ([ca9196fa](https://github.com/angular-ui/bootstrap/commit/ca9196fa), closes [#3987](https://github.com/angular-ui/bootstrap/issues/3987), [#4077](https://github.com/angular-ui/bootstrap/issues/4077)) + + +## Breaking Changes + +* add `open` class to accordion group when expanded + +Closes #4152 +Closes #3419 + + ([ead15e37](https://github.com/angular-ui/bootstrap/commit/ead15e37)) +* Allow the user to hit `esc` inside an element in a modal and not exit the modal if the event has been `defaultPrevented` + +Closes #3551 +Fixes #2544 + + ([a05b9c1a](https://github.com/angular-ui/bootstrap/commit/a05b9c1a)) +* Change validation key to `dateDisabled` to align better with Angular's convention + +Closes #2773 +Closes #4080 + + ([5245ccad](https://github.com/angular-ui/bootstrap/commit/5245ccad)) + + + +# [0.13.2](https://github.com/angular-ui/bootstrap/compare/0.13.1...0.13.2) (2015-08-02) + + +## Bug Fixes + +* **accordion:** apply disabled style to accordion-header ([0643fd3e](https://github.com/angular-ui/bootstrap/commit/0643fd3e), closes [#3599](https://github.com/angular-ui/bootstrap/issues/3599), [#3233](https://github.com/angular-ui/bootstrap/issues/3233)) +* **buttons:** respect disabled attribute ([42e1af5c](https://github.com/angular-ui/bootstrap/commit/42e1af5c), closes [#4026](https://github.com/angular-ui/bootstrap/issues/4026), [#4013](https://github.com/angular-ui/bootstrap/issues/4013)) +* **carousel:** + * fix animations with 1.4 ([f45b4a4c](https://github.com/angular-ui/bootstrap/commit/f45b4a4c), closes [#3946](https://github.com/angular-ui/bootstrap/issues/3946), [#4041](https://github.com/angular-ui/bootstrap/issues/4041), [#3811](https://github.com/angular-ui/bootstrap/issues/3811)) + * clear `currentSlide` when there are no slides ([0c78026b](https://github.com/angular-ui/bootstrap/commit/0c78026b), closes [#4021](https://github.com/angular-ui/bootstrap/issues/4021)) +* **dateparser:** add type and validity check ([4f1e03f1](https://github.com/angular-ui/bootstrap/commit/4f1e03f1), closes [#3701](https://github.com/angular-ui/bootstrap/issues/3701), [#3759](https://github.com/angular-ui/bootstrap/issues/3759), [#3933](https://github.com/angular-ui/bootstrap/issues/3933), [#3609](https://github.com/angular-ui/bootstrap/issues/3609), [#3713](https://github.com/angular-ui/bootstrap/issues/3713), [#3736](https://github.com/angular-ui/bootstrap/issues/3736), [#3875](https://github.com/angular-ui/bootstrap/issues/3875), [#3937](https://github.com/angular-ui/bootstrap/issues/3937), [#3976](https://github.com/angular-ui/bootstrap/issues/3976)) +* **datepicker:** + * change to contains ([9f73d240](https://github.com/angular-ui/bootstrap/commit/9f73d240), closes [#4066](https://github.com/angular-ui/bootstrap/issues/4066), [#3076](https://github.com/angular-ui/bootstrap/issues/3076)) + * *BREAKING CHANGE* remove `new Date` fallback ([ab4580fd](https://github.com/angular-ui/bootstrap/commit/ab4580fd), closes [#2513](https://github.com/angular-ui/bootstrap/issues/2513), [#3294](https://github.com/angular-ui/bootstrap/issues/3294), [#3344](https://github.com/angular-ui/bootstrap/issues/3344), [#3682](https://github.com/angular-ui/bootstrap/issues/3682), [#4092](https://github.com/angular-ui/bootstrap/issues/4092), [#1289](https://github.com/angular-ui/bootstrap/issues/1289), [#2446](https://github.com/angular-ui/bootstrap/issues/2446), [#3037](https://github.com/angular-ui/bootstrap/issues/3037), [#3104](https://github.com/angular-ui/bootstrap/issues/3104), [#3196](https://github.com/angular-ui/bootstrap/issues/3196), [#3206](https://github.com/angular-ui/bootstrap/issues/3206), [#3342](https://github.com/angular-ui/bootstrap/issues/3342), [#3617](https://github.com/angular-ui/bootstrap/issues/3617), [#3644](https://github.com/angular-ui/bootstrap/issues/3644)) + * ensure `initDate` is on an object ([577b2a2a](https://github.com/angular-ui/bootstrap/commit/577b2a2a), closes [#3625](https://github.com/angular-ui/bootstrap/issues/3625)) + * change to higher max date ([32e73280](https://github.com/angular-ui/bootstrap/commit/32e73280), closes [#4042](https://github.com/angular-ui/bootstrap/issues/4042)) + * fix validation with `ngRequired` ([fe0d954a](https://github.com/angular-ui/bootstrap/commit/fe0d954a), closes [#4002](https://github.com/angular-ui/bootstrap/issues/4002), [#3862](https://github.com/angular-ui/bootstrap/issues/3862)) + * set to `null` if not present ([a65a5fa1](https://github.com/angular-ui/bootstrap/commit/a65a5fa1), closes [#4014](https://github.com/angular-ui/bootstrap/issues/4014)) +* **dropdown:** add safety check for setIsOpen ([60e43160](https://github.com/angular-ui/bootstrap/commit/60e43160), closes [#4030](https://github.com/angular-ui/bootstrap/issues/4030)) +* **modal:** + * properly garbage collect DOM node ([1e8297be](https://github.com/angular-ui/bootstrap/commit/1e8297be), closes [#2875](https://github.com/angular-ui/bootstrap/issues/2875)) + * fix `bindToController` implementation ([811bf96e](https://github.com/angular-ui/bootstrap/commit/811bf96e), closes [#4054](https://github.com/angular-ui/bootstrap/issues/4054), [#4051](https://github.com/angular-ui/bootstrap/issues/4051)) + * animate backdrop concurrently with window ([c55ee4f5](https://github.com/angular-ui/bootstrap/commit/c55ee4f5), closes [#4039](https://github.com/angular-ui/bootstrap/issues/4039), [#4036](https://github.com/angular-ui/bootstrap/issues/4036)) +* **progressbar:** + * use more visible color ([1afc5d1d](https://github.com/angular-ui/bootstrap/commit/1afc5d1d), closes [#4044](https://github.com/angular-ui/bootstrap/issues/4044)) + * allow max width of 100% ([2e9177e5](https://github.com/angular-ui/bootstrap/commit/2e9177e5), closes [#4027](https://github.com/angular-ui/bootstrap/issues/4027), [#4018](https://github.com/angular-ui/bootstrap/issues/4018)) +* **tooltip:** + * update tooltip placement dynamically ([13df1c93](https://github.com/angular-ui/bootstrap/commit/13df1c93), closes [#3980](https://github.com/angular-ui/bootstrap/issues/3980), [#3978](https://github.com/angular-ui/bootstrap/issues/3978)) + * prevent 1px shift in Webkit/Blink ([632aa820](https://github.com/angular-ui/bootstrap/commit/632aa820), closes [#3964](https://github.com/angular-ui/bootstrap/issues/3964)) +* **typeahead:** + * reset matches if enter is hit ([25704838](https://github.com/angular-ui/bootstrap/commit/25704838), closes [#4063](https://github.com/angular-ui/bootstrap/issues/4063), [#3545](https://github.com/angular-ui/bootstrap/issues/3545)) + * only reset matches if matches are present ([97e077e1](https://github.com/angular-ui/bootstrap/commit/97e077e1), closes [#3119](https://github.com/angular-ui/bootstrap/issues/3119)) + + +## Features + +* **build:** add support for npm publishing ([27f7ca26](https://github.com/angular-ui/bootstrap/commit/27f7ca26), closes [#3108](https://github.com/angular-ui/bootstrap/issues/3108)) +* **modal:** trap focus in modal for tabbing ([a028d2aa](https://github.com/angular-ui/bootstrap/commit/a028d2aa), closes [#3689](https://github.com/angular-ui/bootstrap/issues/3689), [#4004](https://github.com/angular-ui/bootstrap/issues/4004), [#738](https://github.com/angular-ui/bootstrap/issues/738)) +* **popover:** add custom template support ([a9d3d253](https://github.com/angular-ui/bootstrap/commit/a9d3d253), closes [#4056](https://github.com/angular-ui/bootstrap/issues/4056), [#4057](https://github.com/angular-ui/bootstrap/issues/4057)) +* **rating:** add title support for stars ([713c8487](https://github.com/angular-ui/bootstrap/commit/713c8487), closes [#3621](https://github.com/angular-ui/bootstrap/issues/3621)) +* **typeahead:** + * add `noResults` indicator binding ([647cdd93](https://github.com/angular-ui/bootstrap/commit/647cdd93), closes [#2016](https://github.com/angular-ui/bootstrap/issues/2016), [#2792](https://github.com/angular-ui/bootstrap/issues/2792), [#4068](https://github.com/angular-ui/bootstrap/issues/4068)) + * add `typeaheadSelectOnExact` support ([277b30ca](https://github.com/angular-ui/bootstrap/commit/277b30ca), closes [#3365](https://github.com/angular-ui/bootstrap/issues/3365), [#3310](https://github.com/angular-ui/bootstrap/issues/3310)) + + + +# [0.13.1](https://github.com/angular-ui/bootstrap/compare/0.13.0...0.13.1) (2015-07-23) + + +## Bug Fixes + +* **accordion:** add CSP compliance for accordion & typeahead ([fb302c60](https://github.com/angular-ui/bootstrap/commit/fb302c60), closes [#3909](https://github.com/angular-ui/bootstrap/issues/3909), [#3904](https://github.com/angular-ui/bootstrap/issues/3904)) +* **alert:** + * adjust check for close attribute ([13a0354f](https://github.com/angular-ui/bootstrap/commit/13a0354f), closes [#3864](https://github.com/angular-ui/bootstrap/issues/3864), [#3890](https://github.com/angular-ui/bootstrap/issues/3890), [#3848](https://github.com/angular-ui/bootstrap/issues/3848)) + * rename alert-dismissable to alert-dismissible ([d631af5a](https://github.com/angular-ui/bootstrap/commit/d631af5a)) +* **carousel:** + * change to avoid references to debug info ([ca07ad7c](https://github.com/angular-ui/bootstrap/commit/ca07ad7c), closes [#3795](https://github.com/angular-ui/bootstrap/issues/3795), [#3794](https://github.com/angular-ui/bootstrap/issues/3794)) + * ensure there are slides present ([115d490a](https://github.com/angular-ui/bootstrap/commit/115d490a), closes [#3755](https://github.com/angular-ui/bootstrap/issues/3755)) + * disable transition until animation completes ([ef45ecf8](https://github.com/angular-ui/bootstrap/commit/ef45ecf8), closes [#3729](https://github.com/angular-ui/bootstrap/issues/3729), [#3757](https://github.com/angular-ui/bootstrap/issues/3757)) +* **collapse:** fix occasional flickering ([ede9ea46](https://github.com/angular-ui/bootstrap/commit/ede9ea46), closes [#3804](https://github.com/angular-ui/bootstrap/issues/3804), [#3801](https://github.com/angular-ui/bootstrap/issues/3801)) +* **datepicker:** + * change to min width cells ([5567c432](https://github.com/angular-ui/bootstrap/commit/5567c432), closes [#4000](https://github.com/angular-ui/bootstrap/issues/4000), [#3941](https://github.com/angular-ui/bootstrap/issues/3941)) + * fix OS dependent time zone issue ([f1412014](https://github.com/angular-ui/bootstrap/commit/f1412014), closes [#3079](https://github.com/angular-ui/bootstrap/issues/3079)) + * Apply custom class to month ([eb3b32ec](https://github.com/angular-ui/bootstrap/commit/eb3b32ec), closes [#3863](https://github.com/angular-ui/bootstrap/issues/3863)) + * check if getter.assign is function ([ed10899d](https://github.com/angular-ui/bootstrap/commit/ed10899d), closes [#3155](https://github.com/angular-ui/bootstrap/issues/3155), [#3345](https://github.com/angular-ui/bootstrap/issues/3345), [#3719](https://github.com/angular-ui/bootstrap/issues/3719)) +* **dropdown:** + * do not autoclose with outsideClick and append to body ([cc66a068](https://github.com/angular-ui/bootstrap/commit/cc66a068), closes [#3792](https://github.com/angular-ui/bootstrap/issues/3792), [#3645](https://github.com/angular-ui/bootstrap/issues/3645)) + * avoid matching 138 & 140 ([41ebd984](https://github.com/angular-ui/bootstrap/commit/41ebd984)) + * align when using dropdown-menu-body ([2332f14d](https://github.com/angular-ui/bootstrap/commit/2332f14d), closes [#3913](https://github.com/angular-ui/bootstrap/issues/3913), [#3820](https://github.com/angular-ui/bootstrap/issues/3820)) + * call toggle after animation ([054341b7](https://github.com/angular-ui/bootstrap/commit/054341b7), closes [#3513](https://github.com/angular-ui/bootstrap/issues/3513), [#3655](https://github.com/angular-ui/bootstrap/issues/3655), [#3511](https://github.com/angular-ui/bootstrap/issues/3511)) + * do not close on $locationChangeSuccess ([e5a1e88f](https://github.com/angular-ui/bootstrap/commit/e5a1e88f), closes [#3683](https://github.com/angular-ui/bootstrap/issues/3683), [#3704](https://github.com/angular-ui/bootstrap/issues/3704)) +* **modal:** + * backdrop animation on AngularJS 1.4 ([158d2676](https://github.com/angular-ui/bootstrap/commit/158d2676), closes [#3896](https://github.com/angular-ui/bootstrap/issues/3896)) + * closing breaks on missing scope, 1.4 ([0286828b](https://github.com/angular-ui/bootstrap/commit/0286828b), closes [#3787](https://github.com/angular-ui/bootstrap/issues/3787), [#3806](https://github.com/angular-ui/bootstrap/issues/3806), [#3873](https://github.com/angular-ui/bootstrap/issues/3873), [#3888](https://github.com/angular-ui/bootstrap/issues/3888)) + * remove illegal character ([dd4f3cc8](https://github.com/angular-ui/bootstrap/commit/dd4f3cc8), closes [#3893](https://github.com/angular-ui/bootstrap/issues/3893), [#3892](https://github.com/angular-ui/bootstrap/issues/3892)) + * focus on body if element disappears ([988336cc](https://github.com/angular-ui/bootstrap/commit/988336cc), closes [#3653](https://github.com/angular-ui/bootstrap/issues/3653), [#3639](https://github.com/angular-ui/bootstrap/issues/3639)) +* **progressbar:** use max value on stacked progress bar ([36e0f0ea](https://github.com/angular-ui/bootstrap/commit/36e0f0ea), closes [#3830](https://github.com/angular-ui/bootstrap/issues/3830), [#3618](https://github.com/angular-ui/bootstrap/issues/3618)) +* **rating:** Set rating to 0 when same value is selected ([dbceec76](https://github.com/angular-ui/bootstrap/commit/dbceec76), closes [#3963](https://github.com/angular-ui/bootstrap/issues/3963), [#3246](https://github.com/angular-ui/bootstrap/issues/3246)) +* **tabs:** fix empty href ([2b27dcbf](https://github.com/angular-ui/bootstrap/commit/2b27dcbf), closes [#3799](https://github.com/angular-ui/bootstrap/issues/3799)) +* **typeahead:** + * select match on tab for iOS webview ([5b37bb8b](https://github.com/angular-ui/bootstrap/commit/5b37bb8b), closes [#3762](https://github.com/angular-ui/bootstrap/issues/3762), [#3699](https://github.com/angular-ui/bootstrap/issues/3699)) + * don't close popup on right click ([7d1c4600](https://github.com/angular-ui/bootstrap/commit/7d1c4600), closes [#3975](https://github.com/angular-ui/bootstrap/issues/3975), [#3973](https://github.com/angular-ui/bootstrap/issues/3973)) + * close dropdown on tab with no selection ([493510d0](https://github.com/angular-ui/bootstrap/commit/493510d0), closes [#3340](https://github.com/angular-ui/bootstrap/issues/3340)) + * do not execute unnecessary $digest ([0d96221f](https://github.com/angular-ui/bootstrap/commit/0d96221f), closes [#2652](https://github.com/angular-ui/bootstrap/issues/2652), [#3791](https://github.com/angular-ui/bootstrap/issues/3791)) + * add href to show cursor as pointer ([195e520e](https://github.com/angular-ui/bootstrap/commit/195e520e), closes [#3649](https://github.com/angular-ui/bootstrap/issues/3649)) + + +## Features + +* **alert:** pass $event to close() ([44e06425](https://github.com/angular-ui/bootstrap/commit/44e06425), closes [#3828](https://github.com/angular-ui/bootstrap/issues/3828), [#3827](https://github.com/angular-ui/bootstrap/issues/3827)) +* **carousel:** add `noWrap` option to prevent re-cycling of slides ([7fb3840f](https://github.com/angular-ui/bootstrap/commit/7fb3840f), closes [#3462](https://github.com/angular-ui/bootstrap/issues/3462), [#3397](https://github.com/angular-ui/bootstrap/issues/3397)) +* **collapse:** add accessibility support ([92551342](https://github.com/angular-ui/bootstrap/commit/92551342), closes [#3920](https://github.com/angular-ui/bootstrap/issues/3920)) +* **dropdown:** + * add dropdown classes dynamically ([4af83ade](https://github.com/angular-ui/bootstrap/commit/4af83ade), closes [#3984](https://github.com/angular-ui/bootstrap/issues/3984), [#3986](https://github.com/angular-ui/bootstrap/issues/3986)) + * add accessibility attributes ([14689e05](https://github.com/angular-ui/bootstrap/commit/14689e05), closes [#3951](https://github.com/angular-ui/bootstrap/issues/3951)) + * add keynav support to dropdown ([62359370](https://github.com/angular-ui/bootstrap/commit/62359370), closes [#3685](https://github.com/angular-ui/bootstrap/issues/3685), [#3212](https://github.com/angular-ui/bootstrap/issues/3212), [#1228](https://github.com/angular-ui/bootstrap/issues/1228)) + * support optional templates for dropdown menus ([83c4266c](https://github.com/angular-ui/bootstrap/commit/83c4266c)) +* **modal:** add support for bindToController ([8adfc833](https://github.com/angular-ui/bootstrap/commit/8adfc833), closes [#3965](https://github.com/angular-ui/bootstrap/issues/3965), [#3404](https://github.com/angular-ui/bootstrap/issues/3404)) +* **pagination:** add support for `ng-disabled` ([f6edfa5d](https://github.com/angular-ui/bootstrap/commit/f6edfa5d), closes [#3956](https://github.com/angular-ui/bootstrap/issues/3956)) +* **timepicker:** add `showSpinner` flag ([1f760eb3](https://github.com/angular-ui/bootstrap/commit/1f760eb3)) +* **typeahead:** + * add 'select on blur' option. ([68cac59a](https://github.com/angular-ui/bootstrap/commit/68cac59a), closes [#3445](https://github.com/angular-ui/bootstrap/issues/3445)) + * popup position ([86bfec19](https://github.com/angular-ui/bootstrap/commit/86bfec19), closes [#3874](https://github.com/angular-ui/bootstrap/issues/3874)) + * handles min-length of 0 ([a5a25141](https://github.com/angular-ui/bootstrap/commit/a5a25141), closes [#3600](https://github.com/angular-ui/bootstrap/issues/3600)) + + + +# [0.13.0](https://github.com/angular-ui/bootstrap/compare/0.12.1...0.13.0) (2015-05-02) + + +## Bug Fixes + +* **accordion:** + * Made accordion heading tab-able for IE9-10 ([6abad509](https://github.com/angular-ui/bootstrap/commit/6abad509cd4d44c3ca432f2f21c9ecea0a206b53)) + * noop for href in header to prevent page refresh with nested buttons canceling ev ([9ca4ec39](https://github.com/angular-ui/bootstrap/commit/9ca4ec399be0e0e8f6c3fe6fd924a7d94ce669b5)) +* **buttons:** add unit tests for buttons ([9468d723](https://github.com/angular-ui/bootstrap/commit/9468d7239dd6eed4a3a2945f6761f7a2fa97222b), closes [#3030](https://github.com/angular-ui/bootstrap/issues/3030)) +* **carousel:** respect the order of the slides ([b5f220fa](https://github.com/angular-ui/bootstrap/commit/b5f220fa8483f5743ba6ab3610f5064bf5c71be7), closes [#488](https://github.com/angular-ui/bootstrap/issues/488)) +* **changelog:** add comment on breaking change ([f02c1bbb](https://github.com/angular-ui/bootstrap/commit/f02c1bbbf7777bee9cbb4a038730fb475337a2c5), closes [#2675](https://github.com/angular-ui/bootstrap/issues/2675)) +* **dateparser:** add extra validation constraint to days ([c19b8879](https://github.com/angular-ui/bootstrap/commit/c19b8879e902e8753b4077e7983ec629242436fc)) +* **datepicker:** + * week count issues ([39e5fd3e](https://github.com/angular-ui/bootstrap/commit/39e5fd3e4981f311d237d21c37a89cde9f42f0d6), closes [#2506](https://github.com/angular-ui/bootstrap/issues/2506), [#3120](https://github.com/angular-ui/bootstrap/issues/3120), [#2306](https://github.com/angular-ui/bootstrap/issues/2306)) + * make 'show-weeks' work on datepickerPopup ([d0cc7284](https://github.com/angular-ui/bootstrap/commit/d0cc72841b9641b768cc5eb184de2dbbaa804e2d), closes [#3143](https://github.com/angular-ui/bootstrap/issues/3143), [#3149](https://github.com/angular-ui/bootstrap/issues/3149)) + * datepicker-popup compatibility with ngModelOptions ([d024dd77](https://github.com/angular-ui/bootstrap/commit/d024dd77ed6a20983bea5b90b75481c93f17980f), closes [#3349](https://github.com/angular-ui/bootstrap/issues/3349)) + * disable title button when in max mode ([35b8512a](https://github.com/angular-ui/bootstrap/commit/35b8512ac7949eba7d432760f83748bb2bb893c1), closes [#3012](https://github.com/angular-ui/bootstrap/issues/3012)) + * add shortcutPropagation to datepickerPopup ([13bd516c](https://github.com/angular-ui/bootstrap/commit/13bd516cfcc1b2ee940112675830f7ce3d93ea4f)) + * fixed shortcut event kill by adding option ([89ab4580](https://github.com/angular-ui/bootstrap/commit/89ab4580cd3d0bbdf63e02fbebe2cb3a9f2616dd)) + * fix initDate implementation in datepicker ([98e2bdfc](https://github.com/angular-ui/bootstrap/commit/98e2bdfc8b4dd108acdf3fac75a795675889bbc7)) + * Fix init-date not applying on datepicker-popup ([c5b63ded](https://github.com/angular-ui/bootstrap/commit/c5b63ded0aebd7ae80f238bd0a33f1d9c0746dba)) + * Make datepicker respect dateFormat inside ng-if ([2c2dba6d](https://github.com/angular-ui/bootstrap/commit/2c2dba6d145c95500efe7f6be6248d56ae1aebee)) + * `ng-model` value can be a timestamp ([d253208b](https://github.com/angular-ui/bootstrap/commit/d253208bd7ebbd6209c6a25f70e010123198fcd7), closes [#2345](https://github.com/angular-ui/bootstrap/issues/2345)) + * Parse date from $viewValue instead of $modelValue ([0ecf7faa](https://github.com/angular-ui/bootstrap/commit/0ecf7faad3b340e171e5c8ca17a17597fa6e6596)) + * don't stop ESC propagation unless dropdown is open ([c2e5b284](https://github.com/angular-ui/bootstrap/commit/c2e5b284a31fc992b8230b89dc98ed757d155493), closes [#3096](https://github.com/angular-ui/bootstrap/issues/3096), [#3179](https://github.com/angular-ui/bootstrap/issues/3179)) + * date formatting when using angular 1.3 fixes #2659 ([23936f9f](https://github.com/angular-ui/bootstrap/commit/23936f9f6ec7170b56c7f13345ae022c74fa3034), closes [#3293](https://github.com/angular-ui/bootstrap/issues/3293), [#3279](https://github.com/angular-ui/bootstrap/issues/3279), [#2440](https://github.com/angular-ui/bootstrap/issues/2440), [#2932](https://github.com/angular-ui/bootstrap/issues/2932), [#3074](https://github.com/angular-ui/bootstrap/issues/3074), [#2943](https://github.com/angular-ui/bootstrap/issues/2943), [#2733](https://github.com/angular-ui/bootstrap/issues/2733), [#3047](https://github.com/angular-ui/bootstrap/issues/3047), [#2659](https://github.com/angular-ui/bootstrap/issues/2659), [#2681](https://github.com/angular-ui/bootstrap/issues/2681)) + * date formatting when using angular 1.3 fixes #2659 ([5f9afe5a](https://github.com/angular-ui/bootstrap/commit/5f9afe5a86af0e207eaacd6a9b1f202fd78d7009)) +* **demo:** Modify the demo app to play nice with Angular 1.3 ([aa0b6392](https://github.com/angular-ui/bootstrap/commit/aa0b6392db57d436d7c203cf05555d708c945322), closes [#3098](https://github.com/angular-ui/bootstrap/issues/3098)) +* **dropdown:** Fix $digest:inprog on dropdown dismissal ([4a06adba](https://github.com/angular-ui/bootstrap/commit/4a06adbac6c1c1fa6b0182ca9bd7335eda89c43f), closes [#3274](https://github.com/angular-ui/bootstrap/issues/3274)) +* **grunt:** fix typo in gruntfile ([f0cadb1f](https://github.com/angular-ui/bootstrap/commit/f0cadb1f1a6b8d994c2df3313a2121e92a36bddb), closes [#3589](https://github.com/angular-ui/bootstrap/issues/3589)) +* **modal:** + * Use attribute observe and add a render promise. ([99af5f8a](https://github.com/angular-ui/bootstrap/commit/99af5f8a369b13b018ec1e592d82a1a140cbb6bb)) + * fix minor grammar error ([22a21448](https://github.com/angular-ui/bootstrap/commit/22a21448cbad1448bd4541bd0ce3fc8d3515943d), closes [#3519](https://github.com/angular-ui/bootstrap/issues/3519)) + * Fix focus when the dialog is close or cancelled ([e6b105ae](https://github.com/angular-ui/bootstrap/commit/e6b105ae39bcbcf468c3e32057c6acb5ab50d4ce), closes [#2888](https://github.com/angular-ui/bootstrap/issues/2888)) + * allow for custom user modal sizes ([85eeb954](https://github.com/angular-ui/bootstrap/commit/85eeb95428e0dd367cf3e251f2d01fc8dc899dd4), closes [#3429](https://github.com/angular-ui/bootstrap/issues/3429), [#3431](https://github.com/angular-ui/bootstrap/issues/3431)) + * Autofocus corrects the second time that the modal is open ([e5f5f75b](https://github.com/angular-ui/bootstrap/commit/e5f5f75b370b6d4806da87bec575b7084e30c520), closes [#2802](https://github.com/angular-ui/bootstrap/issues/2802)) + * fix messages on modal test failed ([ab919f9f](https://github.com/angular-ui/bootstrap/commit/ab919f9f89aeaaa336b5d3fe1aed1e0617b5a482)) +* **pagination:** + * remove focus from prior clicked elements ([33269bb6](https://github.com/angular-ui/bootstrap/commit/33269bb6b444cdca18e08063814d39c5ef502589), closes [#3488](https://github.com/angular-ui/bootstrap/issues/3488), [#3486](https://github.com/angular-ui/bootstrap/issues/3486)) + * fixes issue when init called after watch triggered ([26b40903](https://github.com/angular-ui/bootstrap/commit/26b40903abf012b5d3d35154d6210119ac9a76d4), closes [#2257](https://github.com/angular-ui/bootstrap/issues/2257), [#2227](https://github.com/angular-ui/bootstrap/issues/2227)) +* **popover:** + * prevent wrong positioning from title ([c8156c7e](https://github.com/angular-ui/bootstrap/commit/c8156c7e21d0f3a61fc19d59644ae24c423eb5c6), closes [#3518](https://github.com/angular-ui/bootstrap/issues/3518)) + * animations with ngAnimate ([c2ace472](https://github.com/angular-ui/bootstrap/commit/c2ace47225ecb337328a16d2c95744e0d37ce80f), closes [#3509](https://github.com/angular-ui/bootstrap/issues/3509), [#3375](https://github.com/angular-ui/bootstrap/issues/3375), [#3506](https://github.com/angular-ui/bootstrap/issues/3506)) + * make it work with ngAnimate ([461087b5](https://github.com/angular-ui/bootstrap/commit/461087b5ff360e1d06d6724375ab29169a89fcf2), closes [#3482](https://github.com/angular-ui/bootstrap/issues/3482), [#3375](https://github.com/angular-ui/bootstrap/issues/3375)) +* **progressbar:** limit max width to 100% ([489961e1](https://github.com/angular-ui/bootstrap/commit/489961e1a0036ddab4e719a772382209a595f163), closes [#3005](https://github.com/angular-ui/bootstrap/issues/3005)) +* **tab:** change to `disable` attribute ([4bfae223](https://github.com/angular-ui/bootstrap/commit/4bfae2238d08d7edea8e6dfe99051192a16d6587), closes [#2677](https://github.com/angular-ui/bootstrap/issues/2677)) +* **timepicker:** + * move render logic to formatter ([b4bbc019](https://github.com/angular-ui/bootstrap/commit/b4bbc0198284d90085bd85840dffd030345d5211), closes [#3160](https://github.com/angular-ui/bootstrap/issues/3160), [#3427](https://github.com/angular-ui/bootstrap/issues/3427)) + * remove ng-mousewheel binding ([a726b7cd](https://github.com/angular-ui/bootstrap/commit/a726b7cd47d417ed656aef3af012e7b6c7b00bf4), closes [#3442](https://github.com/angular-ui/bootstrap/issues/3442)) + * fix widths of inputs when inside form-inline ([8e89440b](https://github.com/angular-ui/bootstrap/commit/8e89440ba86d26d74ed799843cde7f9f2bf26c0b)) + * Stringify pad return when value >= 10 ([405dab65](https://github.com/angular-ui/bootstrap/commit/405dab65f72f5bed488eb8f5dfa5a1a848c4a606)) +* **tooltip:** + * template type should respect popup class ([6af627a8](https://github.com/angular-ui/bootstrap/commit/6af627a8edcbce0370ca2d90f9a575cd6770f6c1), closes [#3569](https://github.com/angular-ui/bootstrap/issues/3569)) + * tooltip-html should not open if empty ([34044a77](https://github.com/angular-ui/bootstrap/commit/34044a77070bc809220e2ed6b628393889c40b86), closes [#3563](https://github.com/angular-ui/bootstrap/issues/3563)) + * use correct prefix for -template ([9ca9d7f5](https://github.com/angular-ui/bootstrap/commit/9ca9d7f5263d8d5e0a9de18c2b75f64aca7c5255), closes [#3498](https://github.com/angular-ui/bootstrap/issues/3498), [#3473](https://github.com/angular-ui/bootstrap/issues/3473)) + * Fix for issue #3167 ([87a36076](https://github.com/angular-ui/bootstrap/commit/87a3607632cae03ead57b377bb91ca27610e7730)) +* **typeahead:** + * Fix for memory-leak in typeahead ([b5a80c08](https://github.com/angular-ui/bootstrap/commit/b5a80c08f2364db2170766366cc7433661e737ae)) + * reset 'parse' validation key ([c0a9c707](https://github.com/angular-ui/bootstrap/commit/c0a9c707903fa3dfc662887cd348ee2b0bf13f85), closes [#3166](https://github.com/angular-ui/bootstrap/issues/3166)) + * resolve property length of undefined error ([950c22cd](https://github.com/angular-ui/bootstrap/commit/950c22cdab3d0b3479a0d0515ed64e57c9ff046b), closes [#2999](https://github.com/angular-ui/bootstrap/issues/2999), [#3178](https://github.com/angular-ui/bootstrap/issues/3178)) + * set validity if model is set manually ([b0044433](https://github.com/angular-ui/bootstrap/commit/b004443371d75bb61e9a17a00a58b485c1279aee), closes [#3318](https://github.com/angular-ui/bootstrap/issues/3318)) + * $compile match template after adding to DOM ([03446c56](https://github.com/angular-ui/bootstrap/commit/03446c56335d5ee0970f9730af215fea1dd53f48)) + + +## Features + +* **dateparser:** + * Add support for HH, H, mm, m, ss, s formats ([971a1b57](https://github.com/angular-ui/bootstrap/commit/971a1b57394c1e1088564e0809c1341620586aa0), closes [#2509](https://github.com/angular-ui/bootstrap/issues/2509), [#3159](https://github.com/angular-ui/bootstrap/issues/3159), [#3417](https://github.com/angular-ui/bootstrap/issues/3417)) + * add support for milliseconds ([82cb637d](https://github.com/angular-ui/bootstrap/commit/82cb637dd12288d9fa0d42783f672fc71b94ffda), closes [#3537](https://github.com/angular-ui/bootstrap/issues/3537)) +* **datepicker:** + * support HTML5 month input type ([aef8953c](https://github.com/angular-ui/bootstrap/commit/aef8953c792066c008d8ce97918153f756a39112), closes [#3499](https://github.com/angular-ui/bootstrap/issues/3499)) + * support HTML5 date input type ([1a9e88fe](https://github.com/angular-ui/bootstrap/commit/1a9e88fe9e6a078943273012429788e742f8ebbd), closes [#3499](https://github.com/angular-ui/bootstrap/issues/3499)) + * Add custom class to specific days via outside logic ([0bcd30c4](https://github.com/angular-ui/bootstrap/commit/0bcd30c4a6d65763e1c4d1b6820a4537a11e95ee)) +* **dropdown:** + * Dropdown append-to-body ([dfe9854b](https://github.com/angular-ui/bootstrap/commit/dfe9854be3d0ae82f361cefec19a6f692aa41591), closes [#3411](https://github.com/angular-ui/bootstrap/issues/3411), [#1030](https://github.com/angular-ui/bootstrap/issues/1030)) + * Make Auto-Close Dropdowns optional. ([a50f1120](https://github.com/angular-ui/bootstrap/commit/a50f11201eeb18569dca0ac3bb157e43cb9d9076), closes [#2218](https://github.com/angular-ui/bootstrap/issues/2218), [#3045](https://github.com/angular-ui/bootstrap/issues/3045)) +* **modal:** + * Add a vetoable modal.closing event ([a5a82d9b](https://github.com/angular-ui/bootstrap/commit/a5a82d9be7dc0bd1cfd510bacf9aa411c6efe1bc)) + * pass reason when opened promise rejected ([0ad208d6](https://github.com/angular-ui/bootstrap/commit/0ad208d6a0164517b85d8e8181e4a0fc98f4f5ec), closes [#2978](https://github.com/angular-ui/bootstrap/issues/2978)) + * add option to disable animations ([5e661d47](https://github.com/angular-ui/bootstrap/commit/5e661d47d6698fdfebb52402eb1687e5697c0040), closes [#1007](https://github.com/angular-ui/bootstrap/issues/1007), [#2725](https://github.com/angular-ui/bootstrap/issues/2725)) +* **popover:** + * respect popover-class option ([88a41dce](https://github.com/angular-ui/bootstrap/commit/88a41dce86c33bf1e0665d880ca68d3535aed553), closes [#3569](https://github.com/angular-ui/bootstrap/issues/3569)) + * use expression to fix usage with $sce ([422c8234](https://github.com/angular-ui/bootstrap/commit/422c823460663151e3482275d9c6749bb714a415), closes [#3558](https://github.com/angular-ui/bootstrap/issues/3558)) + * add popover-template directive ([7e3179ab](https://github.com/angular-ui/bootstrap/commit/7e3179ab9fdbba6c00ff1c30f97b432b58f9eafb)) +* **progressbar:** allow dynamic update to max ([7ccff028](https://github.com/angular-ui/bootstrap/commit/7ccff028ff5ed19af63d9cf1f5e078223924f3a0)) +* **rating:** add rounding logic to rating value ([b076483c](https://github.com/angular-ui/bootstrap/commit/b076483caa9b672068ae8d0cbdfeaed68f530861), closes [#3413](https://github.com/angular-ui/bootstrap/issues/3413), [#3415](https://github.com/angular-ui/bootstrap/issues/3415)) +* **tabs:** it should not select first not active tab as selected ([91b5fb62](https://github.com/angular-ui/bootstrap/commit/91b5fb62eedbb600d6a6abe32376846f327a903d)) +* **timepicker:** + * always pad minutes ([6324486d](https://github.com/angular-ui/bootstrap/commit/6324486d70edc01fb28f61f0a68a6a490378d602), closes [#1598](https://github.com/angular-ui/bootstrap/issues/1598), [#3533](https://github.com/angular-ui/bootstrap/issues/3533)) + * have up/down arrow keys control time selection ([22961157](https://github.com/angular-ui/bootstrap/commit/22961157ad4636db12336abba54cc893554b99d6)) +* **tooltip:** + * use expression to fix usage with $sce ([d867f830](https://github.com/angular-ui/bootstrap/commit/d867f8302d4632a34c81d151923b497f9af3fcfd), closes [#3558](https://github.com/angular-ui/bootstrap/issues/3558)) + * add tooltip-html directive ([e31fcf0f](https://github.com/angular-ui/bootstrap/commit/e31fcf0fcb06580064d1e6375dbedb69f1c95f25), closes [#3496](https://github.com/angular-ui/bootstrap/issues/3496)) + * add tooltip-template directive ([a1695114](https://github.com/angular-ui/bootstrap/commit/a1695114a245312878d315dfc9e369f98d573eae), closes [#220](https://github.com/angular-ui/bootstrap/issues/220)) + * update position dynamically ([853fa457](https://github.com/angular-ui/bootstrap/commit/853fa4578a1f127fee9283e725d6e19789882121), closes [#96](https://github.com/angular-ui/bootstrap/issues/96), [#1109](https://github.com/angular-ui/bootstrap/issues/1109), [#2816](https://github.com/angular-ui/bootstrap/issues/2816), [#3435](https://github.com/angular-ui/bootstrap/issues/3435)) + * Support for tooltip-class configuration ([d784354a](https://github.com/angular-ui/bootstrap/commit/d784354a53fa40597cb8c124405ea9a90b6f0b8e), closes [#3126](https://github.com/angular-ui/bootstrap/issues/3126)) +* **transition:** deprecate transition module ([8a552443](https://github.com/angular-ui/bootstrap/commit/8a552443741d1e5b4b29d9da9c7e9990fa37886c), closes [#3497](https://github.com/angular-ui/bootstrap/issues/3497)) + + + +# [0.12.1](https://github.com/angular-ui/bootstrap/compare/0.12.0...0.12.1) (2015-02-20) + +## Bug Fixes + +- **tooltip:** + - incorrect position when text wraps ([5726e3ef](http://github.com/angular-ui/bootstrap/commit/5726e3ef)) + + +# [0.12.0](https://github.com/angular-ui/bootstrap/compare/0.11.2...0.12.0) (2014-11-16) + + +## Bug Fixes + +* **accordion:** make header links keyboard accessible ([992b2329](http://github.com/angular-ui/bootstrap/commit/992b23297cd100ab4e236fba469e3a70566a4163), closes [#2869](http://github.com/angular-ui/bootstrap/issues/2869)) +* **build:** make custom builds on demo site work ([390f2bf6](http://github.com/angular-ui/bootstrap/commit/390f2bf6b0846ee640e86ad87bbae8c449e53026), closes [#2960](http://github.com/angular-ui/bootstrap/issues/2960), [#2847](http://github.com/angular-ui/bootstrap/issues/2847), [#2625](http://github.com/angular-ui/bootstrap/issues/2625), [#2489](http://github.com/angular-ui/bootstrap/issues/2489), [#2357](http://github.com/angular-ui/bootstrap/issues/2357), [#2176](http://github.com/angular-ui/bootstrap/issues/2176), [#2892](http://github.com/angular-ui/bootstrap/issues/2892)) +* **carousel:** replaced $timeout with $interval when it was wrong ([392c0ad1](http://github.com/angular-ui/bootstrap/commit/392c0ad13ca9b65be5e77ac0c68de24ead8ea2ce), closes [#1308](http://github.com/angular-ui/bootstrap/issues/1308), [#2454](http://github.com/angular-ui/bootstrap/issues/2454), [#2776](http://github.com/angular-ui/bootstrap/issues/2776)) +* **datepicker:** correct button alignment when using bootstrap v3.2.0 ([460fbec7](http://github.com/angular-ui/bootstrap/commit/460fbec776c6d08d0e7db40aedd29d10ac48d7e9), closes [#2728](http://github.com/angular-ui/bootstrap/issues/2728)) +* **demo:** initial load of fragment URLs ([eab6daf6](http://github.com/angular-ui/bootstrap/commit/eab6daf64b3c963d8e285e254c75af5f97c24ec1), closes [#2762](http://github.com/angular-ui/bootstrap/issues/2762)) +* **dropdown:** + * compatibility with `$location` url rewriting ([ef095170](http://github.com/angular-ui/bootstrap/commit/ef09517061b0b4c0c9e9f85086635af33207ec54), closes [#2343](http://github.com/angular-ui/bootstrap/issues/2343)) + * remove `C` restrictions to avoid conflicts ([192768e1](http://github.com/angular-ui/bootstrap/commit/192768e109b5c4a50c7dcd320e09d05fedd4298a), closes [#2156](http://github.com/angular-ui/bootstrap/issues/2156), [#2170](http://github.com/angular-ui/bootstrap/issues/2170)) +* **tabs:** + * make tab links keyboard accessible ([5df524b7](http://github.com/angular-ui/bootstrap/commit/5df524b77114bccdc9a49540e1eb52a564ee5dfd), closes [#2226](http://github.com/angular-ui/bootstrap/issues/2226), [#2290](http://github.com/angular-ui/bootstrap/issues/2290), [#2870](http://github.com/angular-ui/bootstrap/issues/2870), [#2304](http://github.com/angular-ui/bootstrap/issues/2304)) + * don't select tabs on destroy ([9939867a](http://github.com/angular-ui/bootstrap/commit/9939867aba0b7b763588b18829b557c052ea69ba), closes [#2155](http://github.com/angular-ui/bootstrap/issues/2155), [#2596](http://github.com/angular-ui/bootstrap/issues/2596)) +* **tests:** usage of undefined variables ([34273ff0](http://github.com/angular-ui/bootstrap/commit/34273ff0107ecfa28438a7389d94ca619b8704e5)) +* **tooltip:** + * remove extra digest causing incompatibility ([32c4704b](http://github.com/angular-ui/bootstrap/commit/32c4704b748cecf2de4c651f2e5157c1ef6c88b1), closes [#2951](http://github.com/angular-ui/bootstrap/issues/2951), [#2959](http://github.com/angular-ui/bootstrap/issues/2959)) + * show correct tooltip on `ng-repeat` ([b4832c4b](http://github.com/angular-ui/bootstrap/commit/b4832c4b551af7e580ed65d9e5aaee1ef9e6c53e), closes [#2935](http://github.com/angular-ui/bootstrap/issues/2935)) + * memory leak on show/hide ([faf38d20](http://github.com/angular-ui/bootstrap/commit/faf38d20a49176f2016f7f7d4fa49a5c438a986e), closes [#2709](http://github.com/angular-ui/bootstrap/issues/2709), [#2919](http://github.com/angular-ui/bootstrap/issues/2919)) + * remove child scope requirement ([8204c808](http://github.com/angular-ui/bootstrap/commit/8204c8088139165ac9b2ad3977a2c20570e434cb), closes [#1269](http://github.com/angular-ui/bootstrap/issues/1269), [#2320](http://github.com/angular-ui/bootstrap/issues/2320), [#2203](http://github.com/angular-ui/bootstrap/issues/2203)) + * evaluate appendToBody on init ([e10d561f](http://github.com/angular-ui/bootstrap/commit/e10d561f92c2927be0ec429761fa229520fb9a51), closes [#2921](http://github.com/angular-ui/bootstrap/issues/2921)) + * don't use an empty transclusion fn ([689c4d01](http://github.com/angular-ui/bootstrap/commit/689c4d017d303b6d758164ee12837a172bb01139), closes [#2825](http://github.com/angular-ui/bootstrap/issues/2825)) +* **typeahead:** don't leak DOM nodes ([1f6c3c92](http://github.com/angular-ui/bootstrap/commit/1f6c3c92af0e343c7e34b85ea6d270ac79bf6755)) + + +## Features + +* **alert:** allow alerts to be closed from a controller ([ca6fad67](http://github.com/angular-ui/bootstrap/commit/ca6fad675bf2aa793896bf3e086330667a5d9051), closes [#2399](http://github.com/angular-ui/bootstrap/issues/2399), [#2854](http://github.com/angular-ui/bootstrap/issues/2854)) +* **typeahead:** add focus-first option ([35d0cc1d](http://github.com/angular-ui/bootstrap/commit/35d0cc1d57302883840f7ad54a03918ae2df001d), closes [#908](http://github.com/angular-ui/bootstrap/issues/908), [#2916](http://github.com/angular-ui/bootstrap/issues/2916)) + + +## Breaking Changes + +* `tooltip-trigger` and `popover-trigger` are no longer watched +attributes. +([a65bea95](http://github.com/angular-ui/bootstrap/commit/a65bea95338802b026fd213805b095b5a0b5b393)) +This affects both popovers and tooltips. The *triggers are now set up +once* and can no longer be changed after initialization. + +* `dropdown` and `dropdown-toggle` are attribute-only directives. ([192768e1](http://github.com/angular-ui/bootstrap/commit/192768e109b5c4a50c7dcd320e09d05fedd4298a)) + + Before: + ```html + + ``` + After: + ```html + + ``` + + + +# [0.11.2](https://github.com/angular-ui/bootstrap/compare/0.11.1...0.11.2) (2014-09-26) + +Revert breaking change in **dropdown** ([1a998c4](http://github.com/angular-ui/bootstrap/commit/1a998c4)) + + +# [0.11.1](https://github.com/angular-ui/bootstrap/compare/0.11.0...0.11.1) (2014-09-26) + +## Features + +- **modal:** + - add backdropClass option, similar to windowClass option ([353e6127](http://github.com/angular-ui/bootstrap/commit/353e6127)) + - support alternative controllerAs syntax ([8d7c2a26](http://github.com/angular-ui/bootstrap/commit/8d7c2a26)) + - allow templateUrl to be a function ([990015fb](http://github.com/angular-ui/bootstrap/commit/990015fb)) + +## Bug Fixes + +- **alert:** + - correct binding of alert type class ([aa188aec](http://github.com/angular-ui/bootstrap/commit/aa188aec)) +- **dateparser:** + - do not parse if no format specified ([42cc3f26](http://github.com/angular-ui/bootstrap/commit/42cc3f26)) +- **datepicker:** + - correct `datepicker-mode` binding for popup ([63ae06c9](http://github.com/angular-ui/bootstrap/commit/63ae06c9)) + - memory leak fix for datepicker ([08c150e1](http://github.com/angular-ui/bootstrap/commit/08c150e1)) +- **dropdown:** + - close after selecting an item ([3ac3b487](http://github.com/angular-ui/bootstrap/commit/3ac3b487)) + - remove `C` restrictions to avoid conflicts ([7512b93f](http://github.com/angular-ui/bootstrap/commit/7512b93f)) +- **modal:** + - allow modal.{dismiss,close} to be called again ([1590920c](http://github.com/angular-ui/bootstrap/commit/1590920c)) + - add a work-around for transclusion scope ([0b31e865](http://github.com/angular-ui/bootstrap/commit/0b31e865)) + - allow in-lined controller-as controllers ([79105368](http://github.com/angular-ui/bootstrap/commit/79105368)) + - respect autofocus on child elements ([e62ab94a](http://github.com/angular-ui/bootstrap/commit/e62ab94a)) + - controllerAs not checked ([7b7cdf84](http://github.com/angular-ui/bootstrap/commit/7b7cdf84)) +- **tabs:** + - remove leading newline from a template ([a708fe6d](http://github.com/angular-ui/bootstrap/commit/a708fe6d)) +- **typeahead:** + - timeout cancellation when deleting characters ([5dc57927](http://github.com/angular-ui/bootstrap/commit/5dc57927)) + - allow multiple line expression ([c7db0df4](http://github.com/angular-ui/bootstrap/commit/c7db0df4)) + - replace ng-if with ng-show in matches popup ([a0be450d](http://github.com/angular-ui/bootstrap/commit/a0be450d)) + + +# [0.11.0](https://github.com/angular-ui/bootstrap/compare/0.10.0...0.11.0) (2014-05-01) + +## Features + +- **accordion:** + - support `is-disabled` state ([9c43ae7c](http://github.com/angular-ui/bootstrap/commit/9c43ae7c)) +- **alert:** + - add WAI-ARIA markup ([9a2638bf](http://github.com/angular-ui/bootstrap/commit/9a2638bf)) +- **button:** + - allow uncheckable radio button ([82df4fb1](http://github.com/angular-ui/bootstrap/commit/82df4fb1)) +- **carousel:** + - Support swipe for touchscreen devices ([85140f84](http://github.com/angular-ui/bootstrap/commit/85140f84)) +- **dateParser:** + - add `dateParser` service ([bd2ae0ee](http://github.com/angular-ui/bootstrap/commit/bd2ae0ee)) +- **datepicker:** + - add `datepicker-mode`, `init-date` & today hint ([7f4b40eb](http://github.com/angular-ui/bootstrap/commit/7f4b40eb)) + - make widget accessible ([2423f6d4](http://github.com/angular-ui/bootstrap/commit/2423f6d4)) + - full six-week calendar ([b0b14343](http://github.com/angular-ui/bootstrap/commit/b0b14343)) +- **dropdown:** + - add WAI-ARIA attributes ([22ebd230](http://github.com/angular-ui/bootstrap/commit/22ebd230)) + - focus toggle element when opening or closing with Esc` ([f715d052](http://github.com/angular-ui/bootstrap/commit/f715d052)) +- **dropdownToggle:** + - support programmatic trigger & toggle callback ([ae31079c](http://github.com/angular-ui/bootstrap/commit/ae31079c)) + - add support for `escape` key ([1417c548](http://github.com/angular-ui/bootstrap/commit/1417c548)) +- **modal:** + - support custom template for modal window ([96def3d6](http://github.com/angular-ui/bootstrap/commit/96def3d6)) + - support modal window sizes ([976f6083](http://github.com/angular-ui/bootstrap/commit/976f6083)) + - improve accessibility - add role='dialog' ([60cee9dc](http://github.com/angular-ui/bootstrap/commit/60cee9dc)) +- **pagination:** + - plug into `ngModel` controller ([d65901cf](http://github.com/angular-ui/bootstrap/commit/d65901cf)) +- **progressbar:** + - make widget accessible ([9dfe3157](http://github.com/angular-ui/bootstrap/commit/9dfe3157)) +- **rating:** + - plug into `ngModel` controller ([47e227f6](http://github.com/angular-ui/bootstrap/commit/47e227f6)) + - make widget accessible ([4f56e60e](http://github.com/angular-ui/bootstrap/commit/4f56e60e)) +- **tooltip:** + - support more positioning options ([3704db9a](http://github.com/angular-ui/bootstrap/commit/3704db9a)) +- **typeahead:** + - add WAI-ARIA markup ([5ca23e97](http://github.com/angular-ui/bootstrap/commit/5ca23e97)) + - add `aria-owns` & `aria-activedescendant` roles ([4c76a858](http://github.com/angular-ui/bootstrap/commit/4c76a858)) + +## Bug Fixes + +- **alert:** + - use interpolation for type attribute ([f0a129ad](http://github.com/angular-ui/bootstrap/commit/f0a129ad)) + - add `alert-dismissable` class ([794954af](http://github.com/angular-ui/bootstrap/commit/794954af)) +- **carousel:** + - correct glyphicon ([3b6ab25b](http://github.com/angular-ui/bootstrap/commit/3b6ab25b)) +- **datepicker:** + - remove unneeded date creation ([68cb2e5a](http://github.com/angular-ui/bootstrap/commit/68cb2e5a)) + - `Today` button should not set time ([e1993491](http://github.com/angular-ui/bootstrap/commit/e1993491)) + - mark input field as invalid if the date is invalid ([467dd159](http://github.com/angular-ui/bootstrap/commit/467dd159)) + - rename `dateFormat` to `datepickerPopup` in datepickerPopupConfig ([93da30d5](http://github.com/angular-ui/bootstrap/commit/93da30d5)) + - parse input using dateParser ([e0eb1bce](http://github.com/angular-ui/bootstrap/commit/e0eb1bce)) +- **dropdown:** + - use $animate for adding and removing classes ([e8d5fefc](http://github.com/angular-ui/bootstrap/commit/e8d5fefc)) + - unbind toggle element event on scope destroy ([890e2d37](http://github.com/angular-ui/bootstrap/commit/890e2d37)) + - do not call `on-toggle` initially ([004dd1de](http://github.com/angular-ui/bootstrap/commit/004dd1de)) + - ensure `on-toggle` works when `is-open` is not used ([06ad3bd5](http://github.com/angular-ui/bootstrap/commit/06ad3bd5)) +- **modal:** + - destroy modal scope after animation end ([dfc36fd9](http://github.com/angular-ui/bootstrap/commit/dfc36fd9)) + - backdrop z-index when stacking modals ([94a7f593](http://github.com/angular-ui/bootstrap/commit/94a7f593)) + - give a reason of rejection when escape key pressed ([cb31b875](http://github.com/angular-ui/bootstrap/commit/cb31b875)) + - prevent default event when closing via escape key ([da951222](http://github.com/angular-ui/bootstrap/commit/da951222)) + - toggle 'modal-open' class after animation ([4d641ca7](http://github.com/angular-ui/bootstrap/commit/4d641ca7)) +- **pagination:** + - take maxSize defaults into account ([a294c87f](http://github.com/angular-ui/bootstrap/commit/a294c87f)) +- **position:** + - remove deprecated body scrollTop and scrollLeft ([1ba07c1b](http://github.com/angular-ui/bootstrap/commit/1ba07c1b)) +- **progressbar:** + - allow fractional values for bar width ([0daa7a74](http://github.com/angular-ui/bootstrap/commit/0daa7a74)) + - number filter in bar template and only for percent ([378a9337](http://github.com/angular-ui/bootstrap/commit/378a9337)) +- **tabs:** + - fire deselect before select callback ([7474c47b](http://github.com/angular-ui/bootstrap/commit/7474c47b)) + - use interpolation for type attribute ([83ceb78a](http://github.com/angular-ui/bootstrap/commit/83ceb78a)) + - remove `tabbable` class required for left/right tabs ([19468331](http://github.com/angular-ui/bootstrap/commit/19468331)) +- **timepicker:** + - evaluate correctly the `readonly-input` attribute ([f9b6c496](http://github.com/angular-ui/bootstrap/commit/f9b6c496)) +- **tooltip:** + - animation causes tooltip to hide on show ([2b429f5d](http://github.com/angular-ui/bootstrap/commit/2b429f5d)) +- **typeahead:** + - correctly handle append to body attribute ([10785736](http://github.com/angular-ui/bootstrap/commit/10785736)) + - correctly higlight numeric matches ([09678b12](http://github.com/angular-ui/bootstrap/commit/09678b12)) + - loading callback updates after blur ([6a830116](http://github.com/angular-ui/bootstrap/commit/6a830116)) + - incompatibility with ng-focus ([d0024931](http://github.com/angular-ui/bootstrap/commit/d0024931)) + +## Breaking Changes + +- **alert:** + Use interpolation for type attribute. + + Before: + + ```html + + ``` + or + ```html + + ``` + + After: + + ```html + + ``` + or + ```html + + ``` + +- **datepicker:** + +`show-weeks` is no longer a watched attribute +`*-format` attributes have been renamed to `format-*` +`min` attribute has been renamed to `min-date` +`max` attribute has been renamed to `max-date` +`Open on focus` has been removed. Read more on this ([comment](https://github.com/angular-ui/bootstrap/pull/1922#issuecomment-40491716)). +`dateFormat` renamed to `datepickerPopup` in datepickerPopupConfig + +- **dropdown:** + + Elements with the `dropdown-toggle` directive must have a parent element with the `dropdown` directive. + +- **pagination:** + + Both `pagination` and `pager` are now integrated with `ngModelController`. + * `page` is replaced from `ng-model`. + * `on-select-page` is removed since `ng-change` can now be used. + + Before: + + ```html + + ``` + + After: + + ```html + + ``` + +- **rating:** + `rating` is now integrated with `ngModelController`. + * `value` is replaced from `ng-model`. + + Before: + + ```html + + ``` + + After: + + ```html + + ``` + +- **tabs:** + + Use interpolation for type attribute. + + Before: + + ```html + + + + ``` + + After: + + ```html + + + + ``` + + +# [0.10.0](https://github.com/angular-ui/bootstrap/compare/0.9.0...0.10.0) (2014-01-13) + +_This release adds AngularJS 1.2 support_ + +## Features + +- **modal:** + - expose dismissAll on $modalStack ([bc8d21c1](http://github.com/angular-ui/bootstrap/commit/bc8d21c1)) + +## Bug Fixes + +- **datepicker:** + - evaluate `show-weeks` from `datepicker-options` ([92c1715f](http://github.com/angular-ui/bootstrap/commit/92c1715f)) +- **modal:** + - leaking watchers due to scope re-use ([0754ad7b](http://github.com/angular-ui/bootstrap/commit/0754ad7b)) + - support close animation ([1933488c](http://github.com/angular-ui/bootstrap/commit/1933488c)) +- **timepicker:** + - add correct type for meridian button ([bcf39efe](http://github.com/angular-ui/bootstrap/commit/bcf39efe)) +- **tooltip:** + - performance and scope fixes ([c0df3201](http://github.com/angular-ui/bootstrap/commit/c0df3201)) + + +# [0.9.0](https://github.com/angular-ui/bootstrap/compare/0.8.0...0.9.0) (2013-12-28) + +_This release adds Bootstrap3 support_ + +## Features + +- **accordion:** + - convert to bootstrap3 panel styling ([458a9bd3](http://github.com/angular-ui/bootstrap/commit/458a9bd3)) +- **carousel:** + - some changes for Bootstrap3 ([1f632b65](http://github.com/angular-ui/bootstrap/commit/1f632b65)) +- **collapse:** + - make collapse work with bootstrap3 ([517dff6e](http://github.com/angular-ui/bootstrap/commit/517dff6e)) +- **datepicker:** + - update to Bootstrap 3 ([37684330](http://github.com/angular-ui/bootstrap/commit/37684330)) +- **modal:** + - added bootstrap3 support ([444c488d](http://github.com/angular-ui/bootstrap/commit/444c488d)) +- **pagination:** + - support bootstrap3 ([3db699d7](http://github.com/angular-ui/bootstrap/commit/3db699d7)) +- **progressbar:** + - update to bootstrap3 ([5bcff623](http://github.com/angular-ui/bootstrap/commit/5bcff623)) +- **rating:** + - update rating to bootstrap3 ([7e60284e](http://github.com/angular-ui/bootstrap/commit/7e60284e)) +- **tabs:** + - add nav-justified ([3199dd88](http://github.com/angular-ui/bootstrap/commit/3199dd88)) +- **timepicker:** + - restyled for bootstrap 3 ([6724a721](http://github.com/angular-ui/bootstrap/commit/6724a721)) +- **typeahead:** + - update to Bootstrap 3 ([eadf934a](http://github.com/angular-ui/bootstrap/commit/eadf934a)) + +## Bug Fixes + +- **alert:** + - update template to Bootstrap 3 ([dfc3b0bd](http://github.com/angular-ui/bootstrap/commit/dfc3b0bd)) +- **collapse:** + - Prevent consecutive transitions & tidy up code ([b0032d68](http://github.com/angular-ui/bootstrap/commit/b0032d68)) + - fixes after rebase ([dc02ad1d](http://github.com/angular-ui/bootstrap/commit/dc02ad1d)) +- **rating:** + - user glyhicon classes ([d221d517](http://github.com/angular-ui/bootstrap/commit/d221d517)) +- **timepicker:** + - fix look with bootstrap3 ([9613b61b](http://github.com/angular-ui/bootstrap/commit/9613b61b)) +- **tooltip:** + - re-position tooltip after draw ([a99b3608](http://github.com/angular-ui/bootstrap/commit/a99b3608)) + + +# [0.8.0](https://github.com/angular-ui/bootstrap/compare/0.7.0...0.8.0) (2013-12-28) + +## Features + +- **datepicker:** + - option whether to display button bar in popup ([4d158e0d](http://github.com/angular-ui/bootstrap/commit/4d158e0d)) +- **modal:** + - add modal-open class to body on modal open ([e76512fa](http://github.com/angular-ui/bootstrap/commit/e76512fa)) +- **progressbar:** + - add `max` attribute & support transclusion ([365573ab](http://github.com/angular-ui/bootstrap/commit/365573ab)) +- **timepicker:** + - default meridian labels based on locale ([8b1ab79a](http://github.com/angular-ui/bootstrap/commit/8b1ab79a)) +- **typeahead:** + - add typeahead-append-to-body option ([dd8eac22](http://github.com/angular-ui/bootstrap/commit/dd8eac22)) + +## Bug Fixes + +- **accordion:** + - correct `is-open` handling for dynamic groups ([9ec21286](http://github.com/angular-ui/bootstrap/commit/9ec21286)) +- **carousel:** + - cancel timer on scope destruction ([5b9d929c](http://github.com/angular-ui/bootstrap/commit/5b9d929c)) + - cancel goNext on scope destruction ([7515df45](http://github.com/angular-ui/bootstrap/commit/7515df45)) +- **collapse:** + - dont animate height changes from 0 to 0 ([81e014a8](http://github.com/angular-ui/bootstrap/commit/81e014a8)) +- **datepicker:** + - set default zero time after no date selected ([93cd0df8](http://github.com/angular-ui/bootstrap/commit/93cd0df8)) + - fire `ngChange` on today/clear button press ([6b1c68fb](http://github.com/angular-ui/bootstrap/commit/6b1c68fb)) + - remove datepicker's popup on scope destroy ([48955d69](http://github.com/angular-ui/bootstrap/commit/48955d69)) + - remove edge case position updates ([1fbcb5d6](http://github.com/angular-ui/bootstrap/commit/1fbcb5d6)) +- **modal:** + - put backdrop in before window ([d64f4a97](http://github.com/angular-ui/bootstrap/commit/d64f4a97)) + - grab reference to body when it is needed in lieu of when the factory is created ([dd415a98](http://github.com/angular-ui/bootstrap/commit/dd415a98)) + - focus freshly opened modal ([709e679c](http://github.com/angular-ui/bootstrap/commit/709e679c)) + - properly animate backdrops on each modal opening ([672a557a](http://github.com/angular-ui/bootstrap/commit/672a557a)) +- **tabs:** + - make nested tabs work ([c9acebbe](http://github.com/angular-ui/bootstrap/commit/c9acebbe)) +- **tooltip:** + - update tooltip content when empty ([60515ae1](http://github.com/angular-ui/bootstrap/commit/60515ae1)) + - support IE8 ([5dd98238](http://github.com/angular-ui/bootstrap/commit/5dd98238)) + - unbind element events on scope destroy ([3fe7aa8c](http://github.com/angular-ui/bootstrap/commit/3fe7aa8c)) + - respect animate attribute ([54e614a8](http://github.com/angular-ui/bootstrap/commit/54e614a8)) + +## Breaking Changes + +- **progressbar:** + The onFull/onEmpty handlers & auto/stacked types have been removed. + + To migrate your code change your markup like below. + + Before: + +```html + +``` + + After: + +```html + +``` + + and for stacked instead of passing array/objects you can do: + +```html + +``` + + +# [0.7.0](https://github.com/angular-ui/bootstrap/compare/0.6.0...0.7.0) (2013-11-22) + +## Features + +- **datepicker:** + - add i18n support for bar buttons in popup ([c6ba8d7f](http://github.com/angular-ui/bootstrap/commit/c6ba8d7f)) + - dynamic date format for popup ([aa3eaa91](http://github.com/angular-ui/bootstrap/commit/aa3eaa91)) + - datepicker-append-to-body attribute ([0cdc4609](http://github.com/angular-ui/bootstrap/commit/0cdc4609)) +- **dropdownToggle:** + - disable dropdown when it has the disabled class ([104bdd1b](http://github.com/angular-ui/bootstrap/commit/104bdd1b)) +- **tooltip:** + - add ability to enable / disable tooltip ([5d9bd058](http://github.com/angular-ui/bootstrap/commit/5d9bd058)) + +## Bug Fixes + +- **accordion:** + - assign `is-open` to correct scope ([157f614a](http://github.com/angular-ui/bootstrap/commit/157f614a)) +- **collapse:** + - remove element height watching ([a72c635c](http://github.com/angular-ui/bootstrap/commit/a72c635c)) + - add the "in" class for expanded panels ([9eca35a8](http://github.com/angular-ui/bootstrap/commit/9eca35a8)) +- **datepicker:** + - some IE8 compatibility improvements ([4540476f](http://github.com/angular-ui/bootstrap/commit/4540476f)) + - set popup initial position in append-to-body case ([78a1e9d7](http://github.com/angular-ui/bootstrap/commit/78a1e9d7)) + - properly handle showWeeks config option ([570dba90](http://github.com/angular-ui/bootstrap/commit/570dba90)) +- **modal:** + - correctly close modals with no backdrop ([e55c2de3](http://github.com/angular-ui/bootstrap/commit/e55c2de3)) +- **pagination:** + - fix altering of current page caused by totals change ([81164dae](http://github.com/angular-ui/bootstrap/commit/81164dae)) + - handle extreme values for `total-items` ([8ecf93ed](http://github.com/angular-ui/bootstrap/commit/8ecf93ed)) +- **position:** + - correct positioning for SVG elements ([968e5407](http://github.com/angular-ui/bootstrap/commit/968e5407)) +- **tabs:** + - initial tab selection ([a08173ec](http://github.com/angular-ui/bootstrap/commit/a08173ec)) +- **timepicker:** + - use html5 for input elements ([53709f0f](http://github.com/angular-ui/bootstrap/commit/53709f0f)) +- **tooltip:** + - restore html-unsafe compatibility with AngularJS 1.2 ([08d8b21d](http://github.com/angular-ui/bootstrap/commit/08d8b21d)) + - hide tooltips when content becomes empty ([cf5c27ae](http://github.com/angular-ui/bootstrap/commit/cf5c27ae)) + - tackle DOM node and event handlers leak ([0d810acd](http://github.com/angular-ui/bootstrap/commit/0d810acd)) +- **typeahead:** + - do not set editable error when input is empty ([006986db](http://github.com/angular-ui/bootstrap/commit/006986db)) + - remove popup flickering ([dde804b6](http://github.com/angular-ui/bootstrap/commit/dde804b6)) + - don't show matches if an element is not focused ([d1f94530](http://github.com/angular-ui/bootstrap/commit/d1f94530)) + - fix loading callback when deleting characters ([0149eff6](http://github.com/angular-ui/bootstrap/commit/0149eff6)) + - prevent accidental form submission on ENTER ([253c49ff](http://github.com/angular-ui/bootstrap/commit/253c49ff)) + - evaluate matches source against a correct scope ([fd21214d](http://github.com/angular-ui/bootstrap/commit/fd21214d)) + - support IE8 ([0e9f9980](http://github.com/angular-ui/bootstrap/commit/0e9f9980)) + + +# [0.6.0](https://github.com/angular-ui/bootstrap/compare/0.6.0...0.7.0) (2013-09-08) + +## Features + +- **modal:** + - rewrite $dialog as $modal ([d7a48523](http://github.com/angular-ui/bootstrap/commit/d7a48523)) + - add support for custom window settings ([015625d1](http://github.com/angular-ui/bootstrap/commit/015625d1)) + - expose $close and $dismiss options on modal's scope ([8d153acb](http://github.com/angular-ui/bootstrap/commit/8d153acb)) +- **pagination:** + - `total-items` & optional `items-per-page` API ([e55d9063](http://github.com/angular-ui/bootstrap/commit/e55d9063)) +- **rating:** + - add support for custom icons per instance ([20ab01ad](http://github.com/angular-ui/bootstrap/commit/20ab01ad)) +- **timepicker:** + - plug into `ngModel` controller ([b08e993f](http://github.com/angular-ui/bootstrap/commit/b08e993f)) + +## Bug Fixes + +- **carousel:** + - correct reflow triggering on FFox and Safari ([d34f2de1](http://github.com/angular-ui/bootstrap/commit/d34f2de1)) +- **datepicker:** + - correctly manage focus without jQuery present ([d474824b](http://github.com/angular-ui/bootstrap/commit/d474824b)) + - compatibility with angular 1.1.5 and no jquery ([bf30898d](http://github.com/angular-ui/bootstrap/commit/bf30898d)) + - use $setViewValue for inner changes ([dd99f35d](http://github.com/angular-ui/bootstrap/commit/dd99f35d)) +- **modal:** + - insert backdrop before modal window ([d870f212](http://github.com/angular-ui/bootstrap/commit/d870f212)) + - ie8 fix after $modal rewrite ([ff9d969e](http://github.com/angular-ui/bootstrap/commit/ff9d969e)) + - opening a modal should not change default options ([82532d1b](http://github.com/angular-ui/bootstrap/commit/82532d1b)) + - backdrop should cover previously opened modals ([7fce2fe8](http://github.com/angular-ui/bootstrap/commit/7fce2fe8)) + - allow replacing object with default options ([8e7fbf06](http://github.com/angular-ui/bootstrap/commit/8e7fbf06)) +- **position:** + - fallback for IE8's scrollTop/Left for offset ([9aecd4ed](http://github.com/angular-ui/bootstrap/commit/9aecd4ed)) +- **tabs:** + - add DI array-style annotations ([aac4a0dd](http://github.com/angular-ui/bootstrap/commit/aac4a0dd)) + - evaluate `vertical` on parent scope ([9af6f96e](http://github.com/angular-ui/bootstrap/commit/9af6f96e)) +- **timepicker:** + - add type attribute for meridian button ([1f89fd4b](http://github.com/angular-ui/bootstrap/commit/1f89fd4b)) +- **tooltip:** + - remove placement='mouse' option ([17163c22](http://github.com/angular-ui/bootstrap/commit/17163c22)) +- **typeahead:** + - fix label rendering for equal model and items names ([5de71216](http://github.com/angular-ui/bootstrap/commit/5de71216)) + - set validity flag for non-editable inputs ([366e0c8a](http://github.com/angular-ui/bootstrap/commit/366e0c8a)) + - plug in front of existing parsers ([80cef614](http://github.com/angular-ui/bootstrap/commit/80cef614)) + - highlight return match if no query ([45dd9be1](http://github.com/angular-ui/bootstrap/commit/45dd9be1)) + - keep pop-up on clicking input ([5f9e270d](http://github.com/angular-ui/bootstrap/commit/5f9e270d)) + - remove dependency on ng-bind-html-unsafe ([75893393](http://github.com/angular-ui/bootstrap/commit/75893393)) + +## Breaking Changes + +- **modal:** + +* `$dialog` service was refactored into `$modal` +* `modal` directive was removed - use the `$modal` service instead + +Check the documentation for the `$modal` service to migrate from `$dialog` + +- **pagination:** + API has undergone some changes in order to be easier to use. + * `current-page` is replaced from `page`. + * Number of pages is not defined by `num-pages`, but from `total-items` & + `items-per-page` instead. If `items-per-page` is missing, default is 10. + * `num-pages` still exists but is just readonly. + + Before: + +```html + +``` + + After: + +```html + +``` + +- **tooltip:** + + +The placment='mouse' is gone with no equivalent + + +# [0.5.0](https://github.com/angular-ui/bootstrap/compare/0.4.0...0.5.0) (2013-08-04) ## Features -- **buttons:** - - support dynamic true / false values in btn-checkbox ([3e30cd94](http://github.com/angular-ui/bootstrap/commit/3e30cd94)) -- **datepicker:** - - `ngModelController` plug & new `datepickerPopup` ([dab18336](http://github.com/angular-ui/bootstrap/commit/dab18336)) -- **rating:** - - added onHover and onLeave. ([5b1115e3](http://github.com/angular-ui/bootstrap/commit/5b1115e3)) -- **tabs:** - - added onDeselect callback, used similarly as onSelect ([fe47c9bb](http://github.com/angular-ui/bootstrap/commit/fe47c9bb)) - - add the ability to set the direction of the tabs ([220e7b60](http://github.com/angular-ui/bootstrap/commit/220e7b60)) -- **typeahead:** - - support custom templates for matched items ([e2238174](http://github.com/angular-ui/bootstrap/commit/e2238174)) - - expose index to custom templates ([5ffae83d](http://github.com/angular-ui/bootstrap/commit/5ffae83d)) +- **buttons:** + - support dynamic true / false values in btn-checkbox ([3e30cd94](http://github.com/angular-ui/bootstrap/commit/3e30cd94)) +- **datepicker:** + - `ngModelController` plug & new `datepickerPopup` ([dab18336](http://github.com/angular-ui/bootstrap/commit/dab18336)) +- **rating:** + - added onHover and onLeave. ([5b1115e3](http://github.com/angular-ui/bootstrap/commit/5b1115e3)) +- **tabs:** + - added onDeselect callback, used similarly as onSelect ([fe47c9bb](http://github.com/angular-ui/bootstrap/commit/fe47c9bb)) + - add the ability to set the direction of the tabs ([220e7b60](http://github.com/angular-ui/bootstrap/commit/220e7b60)) +- **typeahead:** + - support custom templates for matched items ([e2238174](http://github.com/angular-ui/bootstrap/commit/e2238174)) + - expose index to custom templates ([5ffae83d](http://github.com/angular-ui/bootstrap/commit/5ffae83d)) ## Bug Fixes -- **datepicker:** - - handle correctly `min`/`max` when cleared ([566bdd16](http://github.com/angular-ui/bootstrap/commit/566bdd16)) - - add type attribute for buttons ([25caf5fb](http://github.com/angular-ui/bootstrap/commit/25caf5fb)) -- **pagination:** - - handle `currentPage` number as string ([b1fa7bb8](http://github.com/angular-ui/bootstrap/commit/b1fa7bb8)) - - use interpolation for text attributes ([f45815cb](http://github.com/angular-ui/bootstrap/commit/f45815cb)) -- **popover:** - - don't unbind event handlers created by other directives ([56f624a2](http://github.com/angular-ui/bootstrap/commit/56f624a2)) - - correctly position popovers appended to body ([93a82af0](http://github.com/angular-ui/bootstrap/commit/93a82af0)) -- **rating:** - - evaluate `max` attribute on parent scope ([60619d51](http://github.com/angular-ui/bootstrap/commit/60619d51)) -- **tabs:** - - make tab contents be correctly connected to parent (#524) ([be7ecff0](http://github.com/angular-ui/bootstrap/commit/be7ecff0)) - - Make tabset template correctly use tabset attributes (#584) ([8868f236](http://github.com/angular-ui/bootstrap/commit/8868f236)) - - fix tab content compiling wrong (Closes #599, #631, #574) ([224bc2f5](http://github.com/angular-ui/bootstrap/commit/224bc2f5)) - - make tabs added with active=true be selected ([360cd5ca](http://github.com/angular-ui/bootstrap/commit/360cd5ca)) - - if tab is active at start, always select it ([ba1f741d](http://github.com/angular-ui/bootstrap/commit/ba1f741d)) -- **timepicker:** - - prevent date change ([ee741707](http://github.com/angular-ui/bootstrap/commit/ee741707)) - - added wheel event to enable mousewheel on Firefox ([8dc92afa](http://github.com/angular-ui/bootstrap/commit/8dc92afa)) -- **tooltip:** - - fix positioning inside scrolling element ([63ae7e12](http://github.com/angular-ui/bootstrap/commit/63ae7e12)) - - triggers should be local to tooltip instances ([58e8ef4f](http://github.com/angular-ui/bootstrap/commit/58e8ef4f)) - - correctly handle initial events unbinding ([4fd5bf43](http://github.com/angular-ui/bootstrap/commit/4fd5bf43)) - - bind correct 'hide' event handler ([d50b0547](http://github.com/angular-ui/bootstrap/commit/d50b0547)) -- **typeahead:** - - play nicelly with existing formatters ([d2df0b35](http://github.com/angular-ui/bootstrap/commit/d2df0b35)) - - properly render initial input value ([c4e169cb](http://github.com/angular-ui/bootstrap/commit/c4e169cb)) - - separate text field rendering and drop down rendering ([ea1e858a](http://github.com/angular-ui/bootstrap/commit/ea1e858a)) - - fixed waitTime functionality ([90a8aa79](http://github.com/angular-ui/bootstrap/commit/90a8aa79)) - - correctly close popup on match selection ([624fd5f5](http://github.com/angular-ui/bootstrap/commit/624fd5f5)) +- **datepicker:** + - handle correctly `min`/`max` when cleared ([566bdd16](http://github.com/angular-ui/bootstrap/commit/566bdd16)) + - add type attribute for buttons ([25caf5fb](http://github.com/angular-ui/bootstrap/commit/25caf5fb)) +- **pagination:** + - handle `currentPage` number as string ([b1fa7bb8](http://github.com/angular-ui/bootstrap/commit/b1fa7bb8)) + - use interpolation for text attributes ([f45815cb](http://github.com/angular-ui/bootstrap/commit/f45815cb)) +- **popover:** + - don't unbind event handlers created by other directives ([56f624a2](http://github.com/angular-ui/bootstrap/commit/56f624a2)) + - correctly position popovers appended to body ([93a82af0](http://github.com/angular-ui/bootstrap/commit/93a82af0)) +- **rating:** + - evaluate `max` attribute on parent scope ([60619d51](http://github.com/angular-ui/bootstrap/commit/60619d51)) +- **tabs:** + - make tab contents be correctly connected to parent (#524) ([be7ecff0](http://github.com/angular-ui/bootstrap/commit/be7ecff0)) + - Make tabset template correctly use tabset attributes (#584) ([8868f236](http://github.com/angular-ui/bootstrap/commit/8868f236)) + - fix tab content compiling wrong (Closes #599, #631, #574) ([224bc2f5](http://github.com/angular-ui/bootstrap/commit/224bc2f5)) + - make tabs added with active=true be selected ([360cd5ca](http://github.com/angular-ui/bootstrap/commit/360cd5ca)) + - if tab is active at start, always select it ([ba1f741d](http://github.com/angular-ui/bootstrap/commit/ba1f741d)) +- **timepicker:** + - prevent date change ([ee741707](http://github.com/angular-ui/bootstrap/commit/ee741707)) + - added wheel event to enable mousewheel on Firefox ([8dc92afa](http://github.com/angular-ui/bootstrap/commit/8dc92afa)) +- **tooltip:** + - fix positioning inside scrolling element ([63ae7e12](http://github.com/angular-ui/bootstrap/commit/63ae7e12)) + - triggers should be local to tooltip instances ([58e8ef4f](http://github.com/angular-ui/bootstrap/commit/58e8ef4f)) + - correctly handle initial events unbinding ([4fd5bf43](http://github.com/angular-ui/bootstrap/commit/4fd5bf43)) + - bind correct 'hide' event handler ([d50b0547](http://github.com/angular-ui/bootstrap/commit/d50b0547)) +- **typeahead:** + - play nicelly with existing formatters ([d2df0b35](http://github.com/angular-ui/bootstrap/commit/d2df0b35)) + - properly render initial input value ([c4e169cb](http://github.com/angular-ui/bootstrap/commit/c4e169cb)) + - separate text field rendering and drop down rendering ([ea1e858a](http://github.com/angular-ui/bootstrap/commit/ea1e858a)) + - fixed waitTime functionality ([90a8aa79](http://github.com/angular-ui/bootstrap/commit/90a8aa79)) + - correctly close popup on match selection ([624fd5f5](http://github.com/angular-ui/bootstrap/commit/624fd5f5)) ## Breaking Changes -- **pagination:** +- **pagination:** The 'first-text', 'previous-text', 'next-text' and 'last-text' attributes are now interpolated. @@ -81,93 +1876,94 @@ ``` -# 0.4.0 (2013-06-24) + +# [0.4.0](https://github.com/angular-ui/bootstrap/compare/0.3.0...0.4.0) (2013-06-24) ## Features -- **buttons:** - - support dynamic values in btn-radio ([e8c5b548](http://github.com/angular-ui/bootstrap/commit/e8c5b548)) -- **carousel:** - - add option to prevent pause ([5f895c13](http://github.com/angular-ui/bootstrap/commit/5f895c13)) -- **datepicker:** - - add datepicker directive ([30a00a07](http://github.com/angular-ui/bootstrap/commit/30a00a07)) -- **pagination:** - - option for different mode when maxSize ([a023d082](http://github.com/angular-ui/bootstrap/commit/a023d082)) - - add pager directive ([d9526475](http://github.com/angular-ui/bootstrap/commit/d9526475)) -- **tabs:** - - Change directive name, add features ([c5326595](http://github.com/angular-ui/bootstrap/commit/c5326595)) - - support disabled state ([2b78dd16](http://github.com/angular-ui/bootstrap/commit/2b78dd16)) - - add support for vertical option ([88d17a75](http://github.com/angular-ui/bootstrap/commit/88d17a75)) - - add support for other navigation types, like 'pills' ([53e0a39f](http://github.com/angular-ui/bootstrap/commit/53e0a39f)) -- **timepicker:** - - add timepicker directive ([9bc5207b](http://github.com/angular-ui/bootstrap/commit/9bc5207b)) -- **tooltip:** +- **buttons:** + - support dynamic values in btn-radio ([e8c5b548](http://github.com/angular-ui/bootstrap/commit/e8c5b548)) +- **carousel:** + - add option to prevent pause ([5f895c13](http://github.com/angular-ui/bootstrap/commit/5f895c13)) +- **datepicker:** + - add datepicker directive ([30a00a07](http://github.com/angular-ui/bootstrap/commit/30a00a07)) +- **pagination:** + - option for different mode when maxSize ([a023d082](http://github.com/angular-ui/bootstrap/commit/a023d082)) + - add pager directive ([d9526475](http://github.com/angular-ui/bootstrap/commit/d9526475)) +- **tabs:** + - Change directive name, add features ([c5326595](http://github.com/angular-ui/bootstrap/commit/c5326595)) + - support disabled state ([2b78dd16](http://github.com/angular-ui/bootstrap/commit/2b78dd16)) + - add support for vertical option ([88d17a75](http://github.com/angular-ui/bootstrap/commit/88d17a75)) + - add support for other navigation types, like 'pills' ([53e0a39f](http://github.com/angular-ui/bootstrap/commit/53e0a39f)) +- **timepicker:** + - add timepicker directive ([9bc5207b](http://github.com/angular-ui/bootstrap/commit/9bc5207b)) +- **tooltip:** - add mouse placement option ([ace7bc60](http://github.com/angular-ui/bootstrap/commit/ace7bc60)) - - add *-append-to-body attribute ([d0896263](http://github.com/angular-ui/bootstrap/commit/d0896263)) - - add custom trigger support ([dfa53155](http://github.com/angular-ui/bootstrap/commit/dfa53155)) -- **typeahead:** - - support typeahead-on-select callback ([91ac17c9](http://github.com/angular-ui/bootstrap/commit/91ac17c9)) - - support wait-ms option ([7f35a3f2](http://github.com/angular-ui/bootstrap/commit/7f35a3f2)) + - add *-append-to-body attribute ([d0896263](http://github.com/angular-ui/bootstrap/commit/d0896263)) + - add custom trigger support ([dfa53155](http://github.com/angular-ui/bootstrap/commit/dfa53155)) +- **typeahead:** + - support typeahead-on-select callback ([91ac17c9](http://github.com/angular-ui/bootstrap/commit/91ac17c9)) + - support wait-ms option ([7f35a3f2](http://github.com/angular-ui/bootstrap/commit/7f35a3f2)) ## Bug Fixes -- **accordion:** +- **accordion:** - allow accordion heading directives as attributes. ([25f6e55c](http://github.com/angular-ui/bootstrap/commit/25f6e55c)) -- **carousel:** - - do not allow user to change slide if transitioning ([1d19663f](http://github.com/angular-ui/bootstrap/commit/1d19663f)) - - make slide 'active' binding optional ([17d6c3b5](http://github.com/angular-ui/bootstrap/commit/17d6c3b5)) - - fix error with deleting multiple slides at once ([3fcb70f0](http://github.com/angular-ui/bootstrap/commit/3fcb70f0)) -- **dialog:** - - remove dialogOpenClass to get in line with v2.3 ([f009b23f](http://github.com/angular-ui/bootstrap/commit/f009b23f)) -- **pagination:** - - bind *-text attributes ([e1bff6b7](http://github.com/angular-ui/bootstrap/commit/e1bff6b7)) -- **progressbar:** - - user `percent` attribute instead of `value`. ([58efec80](http://github.com/angular-ui/bootstrap/commit/58efec80)) -- **tooltip:** - - fix positioning error when appendToBody is set to true ([76fee1f9](http://github.com/angular-ui/bootstrap/commit/76fee1f9)) - - close tooltips appended to body on location change ([041261b5](http://github.com/angular-ui/bootstrap/commit/041261b5)) - - tooltips will hide on scope.$destroy ([3e5a58e5](http://github.com/angular-ui/bootstrap/commit/3e5a58e5)) - - support of custom $interpolate.startSymbol ([88c94ee6](http://github.com/angular-ui/bootstrap/commit/88c94ee6)) - - make sure tooltip scope is evicted from cache ([9246905a](http://github.com/angular-ui/bootstrap/commit/9246905a)) -- **typeahead:** - - return focus to the input after selecting a suggestion ([04a21e33](http://github.com/angular-ui/bootstrap/commit/04a21e33)) +- **carousel:** + - do not allow user to change slide if transitioning ([1d19663f](http://github.com/angular-ui/bootstrap/commit/1d19663f)) + - make slide 'active' binding optional ([17d6c3b5](http://github.com/angular-ui/bootstrap/commit/17d6c3b5)) + - fix error with deleting multiple slides at once ([3fcb70f0](http://github.com/angular-ui/bootstrap/commit/3fcb70f0)) +- **dialog:** + - remove dialogOpenClass to get in line with v2.3 ([f009b23f](http://github.com/angular-ui/bootstrap/commit/f009b23f)) +- **pagination:** + - bind *-text attributes ([e1bff6b7](http://github.com/angular-ui/bootstrap/commit/e1bff6b7)) +- **progressbar:** + - user `percent` attribute instead of `value`. ([58efec80](http://github.com/angular-ui/bootstrap/commit/58efec80)) +- **tooltip:** + - fix positioning error when appendToBody is set to true ([76fee1f9](http://github.com/angular-ui/bootstrap/commit/76fee1f9)) + - close tooltips appended to body on location change ([041261b5](http://github.com/angular-ui/bootstrap/commit/041261b5)) + - tooltips will hide on scope.$destroy ([3e5a58e5](http://github.com/angular-ui/bootstrap/commit/3e5a58e5)) + - support of custom $interpolate.startSymbol ([88c94ee6](http://github.com/angular-ui/bootstrap/commit/88c94ee6)) + - make sure tooltip scope is evicted from cache ([9246905a](http://github.com/angular-ui/bootstrap/commit/9246905a)) +- **typeahead:** + - return focus to the input after selecting a suggestion ([04a21e33](http://github.com/angular-ui/bootstrap/commit/04a21e33)) ## Breaking Changes -- **pagination:** +- **pagination:** The 'first-text', 'previous-text', 'next-text' and 'last-text' attributes are now binded to parent scope. To migrate your code, surround the text of these attributes with quotes. Before: - + ```html ``` After: - + ```html ``` -- **progressbar:** +- **progressbar:** The 'value' is replaced by 'percent'. Before: - + ```html ``` After: - + ```html ``` -- **tabs:** +- **tabs:** The 'tabs' directive has been renamed to 'tabset', and the 'pane' directive has been renamed to 'tab'. @@ -199,8 +1995,8 @@ ``` - -# 0.3.0 (2013-04-30) + +# [0.3.0](https://github.com/angular-ui/bootstrap/compare/0.2.0...0.3.0) (2013-04-30) ## Features @@ -241,7 +2037,8 @@ - correctly higlight matches if query contains regexp-special chars ([467afcd6](https://github.com/angular-ui/bootstrap/commit/467afcd6)) - fix matches pop-up positioning issues ([74beecdb](https://github.com/angular-ui/bootstrap/commit/74beecdb)) -# 0.2.0 (2013-03-03) + +# [0.2.0](https://github.com/angular-ui/bootstrap/compare/0.1.0...0.2.0) (2013-03-03) ## Features @@ -275,6 +2072,7 @@ - **typeahead:** - update inputs value on mapping where label is not derived from the model ([a5f64de](https://github.com/angular-ui/bootstrap/commit/a5f64de)) + # 0.1.0 (2013-02-02) _Very first, initial release_. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..65c05c5748 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,22 @@ +# Contributor Code of Conduct + +As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. + +We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, such as physical or electronic addresses, without explicit permission +* Other unethical or unprofessional conduct. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. + +This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. + +This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 715e13b538..40be9ce425 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,40 @@ +## Got a question or problem? + +Firstly, please go over our FAQ: https://github.com/angular-ui/bootstrap/wiki/FAQ + +Please, do not open issues for the general support questions as we want to keep GitHub issues for bug reports and feature requests. You've got much better chances of getting your question answered on [StackOverflow](http://stackoverflow.com/questions/tagged/angular-ui-bootstrap) where maintainers are looking at questions tagged with `angular-ui-bootstrap`. + +StackOverflow is a much better place to ask questions since: +* there are hundreds of people willing to help on StackOverflow +* questions and answers stay available for public viewing so your question / answer might help someone else +* SO voting system assures that the best answers are prominently visible. + +To save your and our time we will be systematically closing all the issues that are requests for general support and redirecting people to StackOverflow. + +## You think you've found a bug? + +Oh, we are ashamed and want to fix it asap! But before fixing a bug we need to reproduce and confirm it. In order to reproduce bugs we will systematically ask you to provide a _minimal_ reproduce scenario using http://plnkr.co/. Having a live reproduce scenario gives us wealth of important information without going back & forth to you with additional questions like: +* version of AngularJS used +* version of this library that you are using +* 3rd-party libraries used, if any +* and most importantly - a use-case that fails + +A minimal reproduce scenario using http://plnkr.co/ allows us to quickly confirm a bug (or point out coding problem) as well as confirm that we are fixing the right problem. + +We will be insisting on a minimal reproduce scenario in order to save maintainers time and ultimately be able to fix more bugs. Interestingly, from our experience users often find coding problems themselves while preparing a minimal plunk. We understand that sometimes it might be hard to extract essentials bits of code from a larger code-base but we really need to isolate the problem before we can fix it. + +The best part is that you don't need to create plunks from scratch - you can use one from our [demo page](http://angular-ui.github.io/bootstrap/). + +Unfortunately we are not able to investigate / fix bugs without a minimal reproduce scenario using http://plnkr.co/, so if we don't hear back from you we are going to close an issue that don't have enough info to be reproduced. + + +## You want to contribute some code? + We are always looking for the quality contributions and will be happy to accept your Pull Requests as long as those adhere to some basic rules: * Please make sure that your contribution fits well in the project's context: - * we are aiming at rebuilding boostrap directives in pure AngularJS, without any dependencies on any external JavaScript library; - * the only dependency should be boostrap CSS and its markup structure; + * we are aiming at rebuilding bootstrap directives in pure AngularJS, without any dependencies on any external JavaScript library; + * the only dependency should be bootstrap CSS and its markup structure; * directives should be html-agnostic as much as possible which in practice means: * templates should be referred to using the `templateUrl` property * it should be easy to change a default template to a custom one @@ -10,3 +42,4 @@ We are always looking for the quality contributions and will be happy to accept * Please assure that you are submitting quality code, specifically make sure that: * your directive has accompanying tests and all the tests are passing; don't hesitate to contact us (angular-ui@googlegroups.com) if you need any help with unit testing * your PR doesn't break the build; check the Travis-CI build status after opening a PR and push corrective commits if anything goes wrong + * your commits conform to the conventions established [here](https://github.com/stevemao/conventional-changelog-angular/blob/master/convention.md) diff --git a/Gruntfile.js b/Gruntfile.js index 63a92facf7..ed0ec0576a 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,23 +1,16 @@ -var markdown = require('node-markdown').Markdown; +var marked = require('marked'); +var fs = require('fs'); +var _ = require('lodash'); module.exports = function(grunt) { - - grunt.loadNpmTasks('grunt-contrib-watch'); - grunt.loadNpmTasks('grunt-contrib-concat'); - grunt.loadNpmTasks('grunt-contrib-copy'); - grunt.loadNpmTasks('grunt-contrib-jshint'); - grunt.loadNpmTasks('grunt-contrib-uglify'); - grunt.loadNpmTasks('grunt-html2js'); - grunt.loadNpmTasks('grunt-karma'); - grunt.loadNpmTasks('grunt-conventional-changelog'); - grunt.loadNpmTasks('grunt-ngdocs'); + require('load-grunt-tasks')(grunt); // Project configuration. grunt.util.linefeed = '\n'; grunt.initConfig({ - ngversion: '1.0.5', - bsversion: '2.3.1', + ngversion: '1.6.1', + bsversion: '3.3.7', modules: [],//to be filled in by build task pkg: grunt.file.readJSON('package.json'), dist: 'dist', @@ -26,30 +19,46 @@ module.exports = function(grunt) { meta: { modules: 'angular.module("ui.bootstrap", [<%= srcModules %>]);', tplmodules: 'angular.module("ui.bootstrap.tpls", [<%= tplModules %>]);', - all: 'angular.module("ui.bootstrap", ["ui.bootstrap.tpls", <%= srcModules %>]);' + all: 'angular.module("ui.bootstrap", ["ui.bootstrap.tpls", <%= srcModules %>]);', + cssInclude: '', + cssFileBanner: '/* Include this file in your html if you are using the CSP mode. */\n\n', + cssFileDest: '<%= dist %>/<%= filename %>-<%= pkg.version %>-csp.css', + banner: [ + '/*', + ' * <%= pkg.name %>', + ' * <%= pkg.homepage %>\n', + ' * Version: <%= pkg.version %> - <%= grunt.template.today("yyyy-mm-dd") %>', + ' * License: <%= pkg.license %>', + ' */' + ].join('\n') }, delta: { + docs: { + files: ['misc/demo/index.html'], + tasks: ['after-test'] + }, html: { files: ['template/**/*.html'], tasks: ['html2js', 'karma:watch:run'] }, js: { - files: ['src/**/*.js'], - //we don't need to jshint here, it slows down everything else + files: ['src/**/*.js', '!src/**/index.js'], tasks: ['karma:watch:run'] } }, concat: { dist: { options: { - banner: '<%= meta.modules %>\n' + banner: '<%= meta.banner %><%= meta.modules %>\n', + footer: '<%= meta.cssInclude %>' }, src: [], //src filled in by build task dest: '<%= dist %>/<%= filename %>-<%= pkg.version %>.js' }, dist_tpls: { options: { - banner: '<%= meta.all %>\n<%= meta.tplmodules %>\n' + banner: '<%= meta.banner %><%= meta.all %>\n<%= meta.tplmodules %>\n', + footer: '<%= meta.cssInclude %>' }, src: [], //src filled in by build task dest: '<%= dist %>/<%= filename %>-tpls-<%= pkg.version %>.js' @@ -63,28 +72,31 @@ module.exports = function(grunt) { }, files: [{ expand: true, - src: ["**/*.html"], - cwd: "misc/demo/", - dest: "dist/" + src: ['**/*.html'], + cwd: 'misc/demo/', + dest: 'dist/' }] }, demoassets: { files: [{ expand: true, //Don't re-copy html files, we process those - src: ["**/**/*", "!**/*.html"], - cwd: "misc/demo", - dest: "dist/" + src: ['**/**/*', '!**/*.html'], + cwd: 'misc/demo', + dest: 'dist/' }] } }, uglify: { + options: { + banner: '<%= meta.banner %>' + }, dist:{ - src:['<%= dist %>/<%= filename %>-<%= pkg.version %>.js'], + src:['<%= concat.dist.dest %>'], dest:'<%= dist %>/<%= filename %>-<%= pkg.version %>.min.js' }, dist_tpls:{ - src:['<%= dist %>/<%= filename %>-tpls-<%= pkg.version %>.js'], + src:['<%= concat.dist_tpls.dest %>'], dest:'<%= dist %>/<%= filename %>-tpls-<%= pkg.version %>.min.js' } }, @@ -92,7 +104,10 @@ module.exports = function(grunt) { dist: { options: { module: null, // no bundle module for all the html2js templates - base: '.' + base: '.', + rename: function(moduleName) { + return `uib/${moduleName}`; + } }, files: [{ expand: true, @@ -101,20 +116,8 @@ module.exports = function(grunt) { }] } }, - jshint: { - files: ['Gruntfile.js','src/**/*.js'], - options: { - curly: true, - immed: true, - newcap: true, - noarg: true, - sub: true, - boss: true, - eqnull: true, - globals: { - angular: true - } - } + eslint: { + files: ['Gruntfile.js','src/**/*.js'] }, karma: { options: { @@ -128,29 +131,42 @@ module.exports = function(grunt) { }, jenkins: { singleRun: true, + autoWatch: false, colors: false, - reporter: ['dots', 'junit'], - browsers: ['Chrome', 'ChromeCanary', 'Firefox', 'Opera', '/Users/jenkins/bin/safari.sh', '/Users/jenkins/bin/ie9.sh' ,'/Users/jenkins/bin/ie10.sh'] + reporters: ['dots', 'junit'], + browsers: ['Chrome', 'ChromeCanary', 'Firefox', 'Opera', '/Users/jenkins/bin/safari.sh'] }, travis: { singleRun: true, + autoWatch: false, + reporters: ['dots'], browsers: ['Firefox'] + }, + coverage: { + preprocessors: { + 'src/*/*.js': 'coverage' + }, + reporters: ['progress', 'coverage'] } }, - changelog: { + conventionalChangelog: { options: { - dest: 'CHANGELOG.md', - templateFile: 'misc/changelog.tpl.md', - github: 'angular-ui/bootstrap' + changelogOpts: { + preset: 'angular' + }, + templateFile: 'misc/changelog.tpl.md' + }, + release: { + src: 'CHANGELOG.md' } }, shell: { - //We use %version% and evluate it at run-time, because <%= pkg.version %> + //We use %version% and evaluate it at run-time, because <%= pkg.version %> //is only evaluated once 'release-prepare': [ 'grunt before-test after-test', 'grunt version', //remove "-SNAPSHOT" - 'grunt changelog' + 'grunt conventionalChangelog' ], 'release-complete': [ 'git commit CHANGELOG.md package.json -m "chore(release): v%version%"', @@ -161,30 +177,16 @@ module.exports = function(grunt) { 'git commit package.json -m "chore(release): Starting v%version%"' ] }, - ngdocs: { - options: { - dest: 'dist/docs', - scripts: [ - 'angular.js', - '<%= concat.dist_tpls.dest %>' - ], - styles: [ - 'docs/css/style.css' - ], - navTemplate: 'docs/nav.html', - title: 'ui-bootstrap', - html5Mode: false - }, - api: { - src: ["src/**/*.js", "src/**/*.ngdoc"], - title: "API Documentation" - } + 'ddescribe-iit': { + files: [ + 'src/**/*.spec.js' + ] } }); - //register before and after test tasks so we've don't have to change cli - //options on the goole's CI server - grunt.registerTask('before-test', ['enforce', 'jshint', 'html2js']); + //register before and after test tasks so we've don't have to change cli + //options on the google's CI server + grunt.registerTask('before-test', ['enforce', 'ddescribe-iit', 'eslint', 'html2js']); grunt.registerTask('after-test', ['build', 'copy']); //Rename our watch task to 'delta', then make actual 'watch' @@ -195,7 +197,7 @@ module.exports = function(grunt) { // Default task. grunt.registerTask('default', ['before-test', 'test', 'after-test']); - grunt.registerTask('enforce', 'Install commit message enforce script if it doesn\'t exist', function() { + grunt.registerTask('enforce', `Install commit message enforce script if it doesn't exist`, function() { if (!grunt.file.exists('.git/hooks/commit-msg')) { grunt.file.copy('misc/validate-commit-msg.js', '.git/hooks/commit-msg'); require('fs').chmodSync('.git/hooks/commit-msg', '0755'); @@ -220,34 +222,49 @@ module.exports = function(grunt) { }); } function enquote(str) { - return '"' + str + '"'; + return `"${str}"`; + } + function enquoteUibDir(str) { + return enquote(`uib/${str}`); } var module = { name: name, - moduleName: enquote('ui.bootstrap.' + name), + moduleName: enquote(`ui.bootstrap.${name}`), displayName: ucwords(breakup(name, ' ')), - srcFiles: grunt.file.expand("src/"+name+"/*.js"), - tplFiles: grunt.file.expand("template/"+name+"/*.html"), - tpljsFiles: grunt.file.expand("template/"+name+"/*.html.js"), - tplModules: grunt.file.expand("template/"+name+"/*.html").map(enquote), + srcFiles: grunt.file.expand([`src/${name}/*.js`, `!src/${name}/index.js`, `!src/${name}/index-nocss.js`]), + cssFiles: grunt.file.expand(`src/${name}/*.css`), + tplFiles: grunt.file.expand(`template/${name}/*.html`), + tpljsFiles: grunt.file.expand(`template/${name}/*.html.js`), + tplModules: grunt.file.expand(`template/${name}/*.html`).map(enquoteUibDir), dependencies: dependenciesForModule(name), docs: { - md: grunt.file.expand("src/"+name+"/docs/*.md") - .map(grunt.file.read).map(markdown).join("\n"), - js: grunt.file.expand("src/"+name+"/docs/*.js") - .map(grunt.file.read).join("\n"), - html: grunt.file.expand("src/"+name+"/docs/*.html") - .map(grunt.file.read).join("\n") + md: grunt.file.expand(`src/${name}/docs/*.md`) + .map(grunt.file.read).map((str) => marked(str)).join('\n'), + js: grunt.file.expand(`src/${name}/docs/*.js`) + .map(grunt.file.read).join('\n'), + html: grunt.file.expand(`src/${name}/docs/*.html`) + .map(grunt.file.read).join('\n') } }; + + var styles = { + css: [], + js: [] + }; + module.cssFiles.forEach(processCSS.bind(null, module.name, styles, true)); + if (styles.css.length) { + module.css = styles.css.join('\n'); + module.cssJs = styles.js.join('\n'); + } + module.dependencies.forEach(findModule); grunt.config('modules', grunt.config('modules').concat(module)); } function dependenciesForModule(name) { var deps = []; - grunt.file.expand('src/' + name + '/*.js') + grunt.file.expand([`src/${name}/*.js`, `!src/${name}/index.js`, `!src/${name}/index-nocss.js`]) .map(grunt.file.read) .forEach(function(contents) { //Strategy: find where module is declared, @@ -285,25 +302,39 @@ module.exports = function(grunt) { } else { grunt.file.expand({ filter: 'isDirectory', cwd: '.' - }, 'src/*').forEach(function(dir) { + }, 'src/*').forEach((dir) => { findModule(dir.split('/')[1]); }); } var modules = grunt.config('modules'); grunt.config('srcModules', _.pluck(modules, 'moduleName')); - grunt.config('tplModules', _.pluck(modules, 'tplModules').filter(function(tpls) { return tpls.length > 0;} )); + grunt.config('tplModules', _.pluck(modules, 'tplModules').filter((tpls) => tpls.length > 0)); grunt.config('demoModules', modules - .filter(function(module) { - return module.docs.md && module.docs.js && module.docs.html; - }) - .sort(function(a, b) { + .filter((module) => module.docs.md && module.docs.js && module.docs.html) + .sort((a, b) => { if (a.name < b.name) { return -1; } if (a.name > b.name) { return 1; } return 0; }) ); + var cssStrings = _.flatten(_.compact(_.pluck(modules, 'css'))); + var cssJsStrings = _.flatten(_.compact(_.pluck(modules, 'cssJs'))); + if (cssStrings.length) { + grunt.config('meta.cssInclude', cssJsStrings.join('\n')); + + grunt.file.write(grunt.config('meta.cssFileDest'), grunt.config('meta.cssFileBanner') + + cssStrings.join('\n')); + + grunt.log.writeln('File ' + grunt.config('meta.cssFileDest') + ' created'); + } + + var moduleFileMapping = _.clone(modules, true); + moduleFileMapping.forEach((module) => delete module.docs); + + grunt.config('moduleFileMapping', moduleFileMapping); + var srcFiles = _.pluck(modules, 'srcFiles'); var tpljsFiles = _.pluck(modules, 'tpljsFiles'); //Set the concat task to concatenate the given src modules @@ -313,7 +344,7 @@ module.exports = function(grunt) { grunt.config('concat.dist_tpls.src', grunt.config('concat.dist_tpls.src') .concat(srcFiles).concat(tpljsFiles)); - grunt.task.run(['concat', 'uglify']); + grunt.task.run(['concat', 'uglify', 'makeModuleMappingFile', 'makeRawFilesJs', 'makeVersionsMappingFile']); }); grunt.registerTask('test', 'Run tests on singleRun karma server', function() { @@ -322,10 +353,95 @@ module.exports = function(grunt) { if (process.env.TRAVIS) { grunt.task.run('karma:travis'); } else { + var isToRunJenkinsTask = !!this.args.length; + if (grunt.option('coverage')) { + var karmaOptions = grunt.config.get('karma.options'), + coverageOpts = grunt.config.get('karma.coverage'); + grunt.util._.extend(karmaOptions, coverageOpts); + grunt.config.set('karma.options', karmaOptions); + } grunt.task.run(this.args.length ? 'karma:jenkins' : 'karma:continuous'); } }); + grunt.registerTask('makeModuleMappingFile', function() { + var _ = grunt.util._; + var moduleMappingJs = 'dist/assets/module-mapping.json'; + var moduleMappings = grunt.config('moduleFileMapping'); + var moduleMappingsMap = _.object(_.pluck(moduleMappings, 'name'), moduleMappings); + var jsContent = JSON.stringify(moduleMappingsMap); + grunt.file.write(moduleMappingJs, jsContent); + grunt.log.writeln('File ' + moduleMappingJs.cyan + ' created.'); + }); + + grunt.registerTask('makeRawFilesJs', function() { + var _ = grunt.util._; + var jsFilename = 'dist/assets/raw-files.json'; + var genRawFilesJs = require('./misc/raw-files-generator'); + + genRawFilesJs(grunt, jsFilename, _.flatten(grunt.config('concat.dist_tpls.src')), + grunt.config('meta.banner'), grunt.config('meta.cssFileBanner')); + }); + + grunt.registerTask('makeVersionsMappingFile', function() { + var done = this.async(); + + var exec = require('child_process').exec; + + var versionsMappingFile = 'dist/versions-mapping.json'; + + exec('git tag --sort -version:refname', function(error, stdout, stderr) { + // Let's remove the oldest 14 versions. + var versions = stdout.split('\n').slice(0, -14); + var jsContent = versions.map(function(version) { + version = version.replace(/^v/, ''); + return { + version: version, + url: `/bootstrap/versioned-docs/${version}` + }; + }); + jsContent = _.sortBy(jsContent, 'version').reverse(); + jsContent.unshift({ + version: 'Current', + url: '/bootstrap' + }); + grunt.file.write(versionsMappingFile, JSON.stringify(jsContent)); + grunt.log.writeln(`File ${versionsMappingFile.cyan} created.`); + done(); + }); + + }); + + /** + * Logic from AngularJS + * https://github.com/angular/angular.js/blob/36831eccd1da37c089f2141a2c073a6db69f3e1d/lib/grunt/utils.js#L121-L145 + */ + function processCSS(moduleName, state, minify, file) { + var css = fs.readFileSync(file).toString(), + js; + state.css.push(css); + + if (minify) { + css = css + .replace(/\r?\n/g, '') + .replace(/\/\*.*?\*\//g, '') + .replace(/:\s+/g, ':') + .replace(/\s*\{\s*/g, '{') + .replace(/\s*\}\s*/g, '}') + .replace(/\s*\,\s*/g, ',') + .replace(/\s*\;\s*/g, ';'); + } + //escape for js + css = css + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r?\n/g, '\\n'); + js = `angular.module('ui.bootstrap.${moduleName}').run(function() {!angular.$$csp().noInlineStyle && !angular.$$uib${_.capitalize(moduleName)}Css && angular.element(document).find('head').prepend(''); angular.$$uib${_.capitalize(moduleName)}Css = true; });`; + state.js.push(js); + + return state; + } + function setVersion(type, suffix) { var file = 'package.json'; var VERSION_REGEX = /([\'|\"]version[\'|\"][ ]*:[ ]*[\'|\"])([\d|.]*)(-\w+)*([\'|\"])/; diff --git a/LICENSE b/LICENSE index 438d499a42..cf7b84b3fb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2012-2013 the AngularUI Team, https://github.com/organizations/angular-ui/teams/291112 +Copyright (c) 2012-2017 the AngularUI Team, https://github.com/organizations/angular-ui/teams/291112 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 02fe1524f4..8964146889 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,195 @@ -# bootstrap - [AngularJS](http://angularjs.org/) directives specific to [twitter bootstrap](http://twitter.github.io/bootstrap/) +# Project Status (please read) +Due to [Angular](https://angular.io)'s continued adoption, our creation of [the Angular version of this library](https://ng-bootstrap.github.io), and the the project maintainers' moving on to other things, this project is considered feature-complete and is no longer being maintained. -*** +We thank you for all your contributions over the years and hope you've enjoyed using this library as much as we've had developing and maintaining it. It would not have been successful without them. -[](http://travis-ci.org/angular-ui/bootstrap) +--- -## Demo +### UI Bootstrap - [AngularJS](http://angularjs.org/) directives specific to [Bootstrap](http://getbootstrap.com) -Do you want to see directives in action? Visit http://angular-ui.github.io/bootstrap/! +[](https://gitter.im/angular-ui/bootstrap?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[](http://travis-ci.org/angular-ui/bootstrap) +[](https://david-dm.org/angular-ui/bootstrap#info=devDependencies) +[](https://cdnjs.com/libraries/angular-ui-bootstrap/) -## Installation +### Quick links +- [Demo](#demo) +- [Angular 2](#angular-2) +- [Installation](#installation) + - [NPM](#install-with-npm) + - [Bower](#install-with-bower) + - [NuGet](#install-with-nuget) + - [Custom](#custom-build) + - [Manual](#manual-download) +- [Webpack / JSPM](#webpack--jspm) +- [Support](#support) + - [FAQ](#faq) + - [Code of Conduct](#code-of-conduct) + - [PREFIX MIGRATION GUIDE](#prefix-migration-guide) + - [Supported browsers](#supported-browsers) + - [Need help?](#need-help) + - [Found a bug?](#found-a-bug) +- [Contributing to the project](#contributing-to-the-project) +- [Development, meeting minutes, roadmap and more.](#development-meeting-minutes-roadmap-and-more) -Installation is easy as angular-ui-bootstrap has minimal dependencies - only the AngularJS and Bootstrap's CSS are required. -After downloading dependencies (or better yet, referencing them from your favourite CDN) you need to download build version of this project. All the files and their purposes are described here: + +# Demo + +Do you want to see directives in action? Visit https://angular-ui.github.io/bootstrap/! + +# Angular 2 + +Are you interested in Angular 2? We are on our way! Check out [ng-bootstrap](https://github.com/ui-bootstrap/core). + +# Installation + +Installation is easy as UI Bootstrap has minimal dependencies - only the AngularJS and Twitter Bootstrap's CSS are required. +*Notes:* +* Since version 0.13.0, UI Bootstrap depends on [ngAnimate](https://docs.angularjs.org/api/ngAnimate) for transitions and animations, such as the accordion, carousel, etc. Include `ngAnimate` in the module dependencies for your app in order to enable animation. +* UI Bootstrap depends on [ngTouch](https://docs.angularjs.org/api/ngTouch) for swipe actions. Include `ngTouch` in the module dependencies for your app in order to enable swiping. + +## Angular Requirements +* UI Bootstrap 1.0 and higher _requires_ Angular 1.4.x or higher and it has been tested with Angular 1.4.8. +* UI Bootstrap 0.14.3 is the _last_ version that supports Angular 1.3.x. +* UI Bootstrap 0.12.0 is the _last_ version that supports Angular 1.2.x. + +## Bootstrap Requirements +* UI Bootstrap requires Bootstrap CSS version 3.x or higher and it has been tested with Bootstrap CSS 3.3.6. +* UI Bootstrap 0.8 is the _last_ version that supports Bootstrap CSS 2.3.x. + +#### Install with NPM + +```sh +$ npm install angular-ui-bootstrap +``` + +This will install AngularJS and Bootstrap NPM packages. + +#### Install with Bower +```sh +$ bower install angular-bootstrap +``` + +Note: do not install 'angular-ui-bootstrap'. A separate repository - [bootstrap-bower](https://github.com/angular-ui/bootstrap-bower) - hosts the compiled javascript file and bower.json. + +#### Install with NuGet +To install AngularJS UI Bootstrap, run the following command in the Package Manager Console + +```sh +PM> Install-Package Angular.UI.Bootstrap +``` + +#### Custom build + +Head over to https://angular-ui.github.io/bootstrap/ and hit the *Custom build* button to create your own custom UI Bootstrap build, just the way you like it. + +#### Manual download + +After downloading dependencies (or better yet, referencing them from your favorite CDN) you need to download build version of this project. All the files and their purposes are described here: https://github.com/angular-ui/bootstrap/tree/gh-pages#build-files Don't worry, if you are not sure which file to take, opt for `ui-bootstrap-tpls-[version].min.js`. +### Adding dependency to your project + When you are done downloading all the dependencies and project files the only remaining part is to add dependencies on the `ui.bootstrap` AngularJS module: -```javascript +```js angular.module('myModule', ['ui.bootstrap']); ``` -Project files are also available through your favourite package manager: -* **Bower**: `bower install angular-bootstrap` -* **NuGet**: https://nuget.org/packages/Angular.UI.Bootstrap/ +# Webpack / JSPM -## Project philosophy +To use this project with webpack, follow the [NPM](#install-with-npm) instructions. +Now, if you want to use only the accordion, you can do: -### Native, lightweight directives +```js +import accordion from 'angular-ui-bootstrap/src/accordion'; -We are aiming at providing a set of AngularJS directives based on Twitter Bootstrap's markup and CSS. The goal is to provide **native AngularJS directives** without any dependency on jQuery or Bootstrap's JavaScript. -It is often better to rewrite an existing JavaScript code and create a new, pure AngularJS directive. Most of the time the resulting directive is smaller as compared to the orginal JavaScript code size and better integrated into the AngularJS ecosystem. +angular.module('myModule', [accordion]); +``` -### Customizability +You can import all the pieces you need in the same way: -All the directives in this repository should have their markup externalized as templates (loaded via `templateUrl`). In practice it means that you can **customize directive's markup at will**. One could even imagine providing a non-Boostrap version of the templates! +```js +import accordion from 'angular-ui-bootstrap/src/accordion'; +import datepicker from 'angular-ui-bootstrap/src/datepicker'; -### Take what you need and not more +angular.module('myModule', [accordion, datepicker]); +``` -Each directive has its own AngularJS module without any dependencies on other modules or third-pary JavaScript code. In practice it means that you can **just grab the code for the directives you need** and you are not obliged to drag the whole repository. +This will load all the dependencies (if any) and also the templates (if any). -### Quality and stability +Be sure to have a loader able to process `css` files like `css-loader`. -Directives should work. All the time and in all browsers. This is why all the directives have a comprehensive suite of unit tests. All the automated tests are executed on each checkin in several browsers: Chrome, ChromeCanary, Firefox, Opera, Safari, IE9. -In fact we are fortunate enough to **benefit from the same testing infrastructure as AngularJS**! +If you would prefer not to load your css through your JavaScript file loader/bundler, you can choose to import the `index-nocss.js` file instead, which is available for the modules: +* carousel +* datepicker +* datepickerPopup +* dropdown +* modal +* popover +* position +* timepicker +* tooltip +* typeahead -## Contributing to the project +The other modules, such as `accordion` in the example below, do not have CSS resources to load, so you should continue to import them as normal: + +```js +import accordion from 'angular-ui-bootstrap/src/accordion'; +import typeahead from 'angular-ui-bootstrap/src/typeahead/index-nocss.js'; + +angular.module('myModule', [accordion, typeahead]); +``` + +# Versioning + +Pre-2.0.0 does not follow a particular versioning system. 2.0.0 and onwards follows [semantic versioning](http://semver.org/). All release changes can be viewed on our [changelog](CHANGELOG.md). + +# Support + +## FAQ + +https://github.com/angular-ui/bootstrap/wiki/FAQ + +# Code of Conduct + +Take a moment to read our [Code of Conduct](CODE_OF_CONDUCT.md) + +## PREFIX MIGRATION GUIDE + +If you're updating your application to use prefixes, please check the [migration guide](https://github.com/angular-ui/bootstrap/wiki/Migration-guide-for-prefixes). + +## Supported browsers + +Directives from this repository are automatically tested with the following browsers: +* Chrome (stable and canary channel) +* Firefox +* IE 9 and 10 +* Opera +* Safari + +Modern mobile browsers should work without problems. + +## Need help? +Need help using UI Bootstrap? + +* Live help in the IRC (`#angularjs` channel at the `freenode` network). Use this [webchat](https://webchat.freenode.net/) or your own IRC client. +* Ask a question in [StackOverflow](http://stackoverflow.com/) under the [angular-ui-bootstrap](http://stackoverflow.com/questions/tagged/angular-ui-bootstrap) tag. + +**Please do not create new issues in this repository to ask questions about using UI Bootstrap** + +## Found a bug? +Please take a look at [CONTRIBUTING.md](CONTRIBUTING.md#you-think-youve-found-a-bug) and submit your issue [here](https://github.com/angular-ui/bootstrap/issues/new). + + +---- + + +# Contributing to the project We are always looking for the quality contributions! Please check the [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution guidelines. -### Development -#### Prepare your environment -* Install [Node.js](http://nodejs.org/) and NPM (should come with) -* Install global dev dependencies: `npm install -g grunt-cli karma` -* Instal local dev dependencies: `npm install` while current directory is bootstrap repo - -#### Build -* Build the whole project: `grunt` - this will run `lint`, `test`, and `concat` targets - -Check the Grunt build file for other tasks that are defined for this project - -#### TDD -* Run test: `grunt watch` - -This will start Karma server and will continously watch files in the project, executing tests upon every change. - -### Release -* Bump up version number in `package.json` -* Commit the version change with the following message: `chore(release): [version number]` -* tag -* push changes and a tag (`git push --tags`) -* switch to the `gh-pages` branch: `git checkout gh-pages` -* copy content of the dist folder to the main folder -* Commit the version change with the following message: `chore(release): [version number]` -* push changes -* switch back to the `main branch` and modify `package.json` to bump up version for the next iteration -* commit (`chore(release): starting [version number]`) and push -* publish Bower and NuGet packages - -Well done! (If you don't like repeating yourself open a PR with a grunt task taking care of the above!) +# Development, meeting minutes, roadmap and more. + +Head over to the [Wiki](https://github.com/angular-ui/bootstrap/wiki) for notes on development for UI Bootstrap, meeting minutes from the UI Bootstrap team, roadmap plans, project philosophy and more. diff --git a/docs/css/style.css b/docs/css/style.css deleted file mode 100644 index 0bbd3ad262..0000000000 --- a/docs/css/style.css +++ /dev/null @@ -1,21 +0,0 @@ -.bs-docs-social { - margin-top: 1em; - padding: 15px 0; - text-align: center; - background-color: rgba(245,245,245,0.3); - border-top: 1px solid rgba(255,255,255,0.3); - border-bottom: 1px solid rgba(221,221,221,0.3); -} -.bs-docs-social-buttons { - position: absolute; - top: 8px; - margin-left: 0; - margin-bottom: 0; - padding-left: 0; - list-style: none; -} -.bs-docs-social-buttons li { - list-style: none; - display: inline-block; - line-height: 1; -} diff --git a/docs/nav.html b/docs/nav.html deleted file mode 100644 index 078fd91200..0000000000 --- a/docs/nav.html +++ /dev/null @@ -1,13 +0,0 @@ - - - Download (<%= pkg.version%>) - - - - - - - - - diff --git a/index.js b/index.js new file mode 100644 index 0000000000..722c734ea3 --- /dev/null +++ b/index.js @@ -0,0 +1,3 @@ +require('./dist/ui-bootstrap-tpls'); + +module.exports = 'ui.bootstrap'; diff --git a/karma.conf.js b/karma.conf.js index 15d329e4ec..86429337dd 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,55 +1,81 @@ +// Karma configuration +// Generated on Sat Mar 28 2015 11:17:34 GMT-0700 (PDT) -// base path, that will be used to resolve files and exclude -basePath = '.'; - -// list of files / patterns to load in the browser -files = [ - JASMINE, - JASMINE_ADAPTER, - 'misc/test-lib/jquery-1.8.2.min.js', - 'misc/test-lib/angular.js', - 'misc/test-lib/angular-mocks.js', - 'misc/test-lib/helpers.js', - 'src/**/*.js', - 'template/**/*.js' -]; - -// list of files to exclude -exclude = [ -]; - -// Start these browsers, currently available: -// - Chrome -// - ChromeCanary -// - Firefox -// - Opera -// - Safari -// - PhantomJS -browsers = [ - 'Chrome' -]; - -// test results reporter to use -// possible values: dots || progress -reporter = 'progress'; - -// web server port -port = 9018; - -// cli runner port -runnerPort = 9100; - -// enable / disable colors in the output (reporters and logs) -colors = true; - -// level of logging -// possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG -logLevel = LOG_INFO; - -// enable / disable watching file and executing tests whenever any file changes -autoWatch = false; - -// Continuous Integration mode -// if true, it capture browsers, run tests and exit -singleRun = false; +module.exports = function(config) { + config.set({ + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: '', + + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['jasmine'], + + + // list of files / patterns to load in the browser + files: [ + 'misc/test-lib/jquery-1.8.2.min.js', + 'node_modules/angular/angular.js', + 'node_modules/angular-mocks/angular-mocks.js', + 'node_modules/angular-sanitize/angular-sanitize.js', + 'misc/test-lib/helpers.js', + 'src/**/*.js', + 'template/**/*.js' + ], + + + // list of files to exclude + exclude: [ + 'src/**/index.js', + 'src/**/index-nocss.js', + 'src/**/docs/*' + ], + + + // preprocess matching files before serving them to the browser + // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor + preprocessors: { + 'src/*/{*.js,!(test)/**/*.js}': ['coverage'] + }, + + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ['progress', 'coverage'], + + coverageReporter: { + dir: '.coverage/', + type: 'html' + }, + + reportSlowerThan: 100, + + // web server port + port: 9876, + + + // enable / disable colors in the output (reporters and logs) + colors: true, + + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + browsers: ['Chrome'], + + + // Continuous Integration mode + // if true, Karma captures browsers, runs the tests and exits + singleRun: false + }); +}; diff --git a/misc/changelog.tpl.md b/misc/changelog.tpl.md index 712c490db9..846284e114 100644 --- a/misc/changelog.tpl.md +++ b/misc/changelog.tpl.md @@ -3,12 +3,12 @@ ## Features <% _(changelog.feat).keys().sort().forEach(function(scope) { %> - **<%= scope%>:** <% changelog.feat[scope].forEach(function(change) { %> - - <%= change.msg%> (<%= helpers.commitLink(change.sha1) %>) <% }); %><% }); %> <% } %> + - <%= change.msg%> (<%= helpers.commitLink(change.sha1) %>)<% }); %><% }); %> <% } %> <% if (_(changelog.fix).size() > 0) { %> ## Bug Fixes <% _(changelog.fix).keys().sort().forEach(function(scope) { %> - **<%= scope%>:** <% changelog.fix[scope].forEach(function(change) { %> - - <%= change.msg%> (<%= helpers.commitLink(change.sha1) %>) <% }); %><% }); %> <% } %> + - <%= change.msg%> (<%= helpers.commitLink(change.sha1) %>)<% }); %><% }); %> <% } %> <% if (_(changelog.breaking).size() > 0) { %> ## Breaking Changes <% _(changelog.breaking).keys().sort().forEach(function(scope) { %> diff --git a/misc/demo/assets/app.js b/misc/demo/assets/app.js index 7a5e7a61d0..4f4cd41b24 100644 --- a/misc/demo/assets/app.js +++ b/misc/demo/assets/app.js @@ -1,31 +1,317 @@ +/* global FastClick, smoothScroll */ +angular.module('ui.bootstrap.demo', ['ui.bootstrap', 'plunker', 'ngTouch', 'ngAnimate', 'ngSanitize'], function($httpProvider){ + if (!!window.FastClick) { + FastClick.attach(document.body); + } + delete $httpProvider.defaults.headers.common['X-Requested-With']; +}).run(['$location', function($location){ + //Allows us to navigate to the correct element on initialization + if ($location.path() !== '' && $location.path() !== '/') { + smoothScroll(document.getElementById($location.path().substring(1)), 500, function(el) { + location.replace('#' + el.id); + }); + } +}]).factory('buildFilesService', function ($http, $q) { -angular.module('bootstrapDemoApp', ['ui.directives', 'ui.bootstrap', 'plunker']); + var moduleMap; + var rawFiles; -function MainCtrl($scope, $http, orderByFilter) { - var url = "http://50.116.42.77:3001/"; - $scope.selectedModules = []; - //iFrame for downloading - var $iframe = $("").css('display','none').appendTo(document.body); + return { + getModuleMap: getModuleMap, + getRawFiles: getRawFiles, + get: function () { + return $q.all({ + moduleMap: getModuleMap(), + rawFiles: getRawFiles() + }); + } + }; - $scope.showBuildModal = function() { - $scope.buildModalShown = true; - //Load modules if they aren't loaded yet - if (!$scope.modules) { - $http.get(url + "/api/bootstrap").then(function(response) { - $scope.modules = response.data.modules; - }, function() { - $scope.buildGetErrorText = "Error retrieving build files from server."; + function getModuleMap() { + return moduleMap ? $q.when(moduleMap) : $http.get('assets/module-mapping.json') + .then(function (result) { + moduleMap = result.data; + return moduleMap; }); + } + + function getRawFiles() { + return rawFiles ? $q.when(rawFiles) : $http.get('assets/raw-files.json') + .then(function (result) { + rawFiles = result.data; + return rawFiles; + }); + } + +}) +.controller('MainCtrl', MainCtrl) +.controller('SelectModulesCtrl', SelectModulesCtrl) +.controller('DownloadCtrl', DownloadCtrl); + +function MainCtrl($scope, $http, $document, $uibModal, orderByFilter) { + // Grab old version docs + $http.get('/bootstrap/versions-mapping.json') + .then(function(result) { + $scope.oldDocs = result.data; + }); + + $scope.showBuildModal = function() { + var modalInstance = $uibModal.open({ + templateUrl: 'buildModal.html', + controller: 'SelectModulesCtrl', + resolve: { + modules: function(buildFilesService) { + return buildFilesService.getModuleMap() + .then(function (moduleMap) { + return Object.keys(moduleMap); + }); + } + } + }); + }; + + $scope.showDownloadModal = function() { + var modalInstance = $uibModal.open({ + templateUrl: 'downloadModal.html', + controller: 'DownloadCtrl' + }); + }; +} + +function SelectModulesCtrl($scope, $uibModalInstance, modules, buildFilesService) { + $scope.selectedModules = []; + $scope.modules = modules; + + $scope.selectedChanged = function(module, selected) { + if (selected) { + $scope.selectedModules.push(module); + } else { + $scope.selectedModules.splice($scope.selectedModules.indexOf(module), 1); } }; - $scope.downloadBuild = function() { - var downloadUrl = url + "/api/bootstrap/download?"; - angular.forEach($scope.selectedModules, function(module) { - downloadUrl += "modules=" + module + "&"; + $scope.downloadBuild = function () { + $uibModalInstance.close($scope.selectedModules); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss(); + }; + + $scope.isOldBrowser = function () { + return isOldBrowser; + }; + + $scope.build = function (selectedModules, version) { + /* global JSZip, saveAs */ + var moduleMap, rawFiles; + + buildFilesService.get().then(function (buildFiles) { + moduleMap = buildFiles.moduleMap; + rawFiles = buildFiles.rawFiles; + + generateBuild(); }); - $iframe.attr('src',''); - $iframe.attr('src', downloadUrl); - $scope.buildModalShown = false; + + function generateBuild() { + var srcModuleNames = selectedModules + .map(function (module) { + return moduleMap[module]; + }) + .reduce(function (toBuild, module) { + addIfNotExists(toBuild, module.name); + + module.dependencies.forEach(function (depName) { + addIfNotExists(toBuild, depName); + }); + return toBuild; + }, []); + + var srcModules = srcModuleNames + .map(function (moduleName) { + return moduleMap[moduleName]; + }); + + var srcModuleFullNames = srcModules + .map(function (module) { + return module.moduleName; + }); + + var srcJsContent = srcModules + .reduce(function (buildFiles, module) { + return buildFiles.concat(module.srcFiles); + }, []) + .map(getFileContent) + .join('\n') + ; + + var jsFile = createNoTplFile(srcModuleFullNames, srcJsContent); + + var tplModuleNames = srcModules + .reduce(function (tplModuleNames, module) { + return tplModuleNames.concat(module.tplModules); + }, []); + + var tplJsContent = srcModules + .reduce(function (buildFiles, module) { + return buildFiles.concat(module.tpljsFiles); + }, []) + .map(getFileContent) + .join('\n') + ; + + var jsTplFile = createWithTplFile(srcModuleFullNames, srcJsContent, tplModuleNames, tplJsContent); + + var cssContent = srcModules + .map(function (module) { + return module.css; + }) + .filter(function (css) { + return css; + }) + .join('\n') + ; + + var cssJsContent = srcModules + .map(function (module) { + return module.cssJs; + }) + .filter(function (cssJs) { + return cssJs; + }) + .join('\n') + ; + + var footer = cssJsContent; + + var zip = new JSZip(); + zip.file('ui-bootstrap-custom-' + version + '.js', rawFiles.banner + jsFile + footer); + zip.file('ui-bootstrap-custom-' + version + '.min.js', rawFiles.banner + uglify(jsFile + footer)); + zip.file('ui-bootstrap-custom-tpls-' + version + '.js', rawFiles.banner + jsTplFile + footer); + zip.file('ui-bootstrap-custom-tpls-' + version + '.min.js', rawFiles.banner + uglify(jsTplFile + footer)); + zip.file('ui-bootstrap-custom-tpls-' + version + '.min.js', rawFiles.banner + uglify(jsTplFile + footer)); + + if (cssContent) { + zip.file('ui-bootstrap-custom-' + version + '-csp.css', rawFiles.cssBanner + cssContent); + } + + saveAs(zip.generate({type: 'blob'}), 'ui-bootstrap-custom-build.zip'); + } + + function createNoTplFile(srcModuleNames, srcJsContent) { + return 'angular.module("ui.bootstrap", [' + srcModuleNames.join(',') + ']);\n' + + srcJsContent; + } + + function createWithTplFile(srcModuleNames, srcJsContent, tplModuleNames, tplJsContent) { + var depModuleNames = srcModuleNames.slice(); + depModuleNames.unshift('"ui.bootstrap.tpls"'); + + return 'angular.module("ui.bootstrap", [' + depModuleNames.join(',') + ']);\n' + + 'angular.module("ui.bootstrap.tpls", [' + tplModuleNames.join(',') + ']);\n' + + srcJsContent + '\n' + tplJsContent; + + } + + function addIfNotExists(array, element) { + if (array.indexOf(element) == -1) { + array.push(element); + } + } + + function getFileContent(fileName) { + return rawFiles.files[fileName]; + } + + function uglify(js) { + /* global UglifyJS */ + + var ast = UglifyJS.parse(js); + ast.figure_out_scope(); + + var compressor = UglifyJS.Compressor(); + var compressedAst = ast.transform(compressor); + + compressedAst.figure_out_scope(); + compressedAst.compute_char_frequency(); + compressedAst.mangle_names(); + + var stream = UglifyJS.OutputStream(); + compressedAst.print(stream); + + return stream.toString(); + } + }; +} + +function DownloadCtrl($scope, $uibModalInstance) { + $scope.options = { + minified: true, + tpls: true + }; + + $scope.download = function (version) { + var options = $scope.options; + + var downloadUrl = ['ui-bootstrap-']; + if (options.tpls) { + downloadUrl.push('tpls-'); + } + downloadUrl.push(version); + if (options.minified) { + downloadUrl.push('.min'); + } + downloadUrl.push('.js'); + + return downloadUrl.join(''); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss(); }; } + +/* + * The following compatibility check is from: + * + * Bootstrap Customizer (http://getbootstrap.com/customize/) + * Copyright 2011-2014 Twitter, Inc. + * + * Licensed under the Creative Commons Attribution 3.0 Unported License. For + * details, see http://creativecommons.org/licenses/by/3.0/. + */ +var isOldBrowser; +(function () { + + var supportsFile = (window.File && window.FileReader && window.FileList && window.Blob); + function failback() { + isOldBrowser = true; + } + /** + * Based on: + * Blob Feature Check v1.1.0 + * https://github.com/ssorallen/blob-feature-check/ + * License: Public domain (http://unlicense.org) + */ + var url = window.URL; + var svg = new Blob( + [''], + { type: 'image/svg+xml;charset=utf-8' } + ); + var objectUrl = url.createObjectURL(svg); + + if (/^blob:/.exec(objectUrl) === null || !supportsFile) { + // `URL.createObjectURL` created a URL that started with something other + // than "blob:", which means it has been polyfilled and is not supported by + // this browser. + failback(); + } else { + angular.element('') + .on('load', function () { + isOldBrowser = false; + }) + .on('error', failback) + .attr('src', objectUrl); + } + + })(); diff --git a/misc/demo/assets/demo.css b/misc/demo/assets/demo.css index ddad9986b5..994d63b1dd 100644 --- a/misc/demo/assets/demo.css +++ b/misc/demo/assets/demo.css @@ -32,65 +32,292 @@ section { background-color: #f5f5f5; } -.hero-unit { - position: relative; - padding: 40px 0; - color: #fff; +.bs-social { + margin-top: 20px; + margin-bottom: 20px; text-align: center; - text-shadow: 0 1px 3px rgba(0,0,0,.4), 0 0 30px rgba(0,0,0,.075); - background: #020031; - background: -moz-linear-gradient(45deg, #020031 0%, #6d3353 100%); - background: -webkit-gradient(linear, left bottom, right top, color-stop(0%,#020031), color-stop(100%,#6d3353)); - background: -webkit-linear-gradient(45deg, #020031 0%,#6d3353 100%); - background: -o-linear-gradient(45deg, #020031 0%,#6d3353 100%); - background: -ms-linear-gradient(45deg, #020031 0%,#6d3353 100%); - background: linear-gradient(45deg, #020031 0%,#6d3353 100%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#020031', endColorstr='#6d3353',GradientType=1 ); - -webkit-box-shadow: inset 0 3px 7px rgba(0,0,0,.2), inset 0 -3px 7px rgba(0,0,0,.2); - -moz-box-shadow: inset 0 3px 7px rgba(0,0,0,.2), inset 0 -3px 7px rgba(0,0,0,.2); - box-shadow: inset 0 3px 7px rgba(0,0,0,.2), inset 0 -3px 7px rgba(0,0,0,.2); - border-radius: 0; - -moz-border-radius: 0; - -webkit-border-radius: 0; - -o-border-radius: 0; -} -.hero-unit .btn, .pagination-centered .btn { - float: none; - font-weight: normal; -} -.hero-unit p { - margin: 1em 0; -} -.bs-docs-social { - margin-top: 1em; - padding: 15px 0; - text-align: center; - background-color: rgba(245,245,245,0.3); - border-top: 1px solid rgba(255,255,255,0.3); - border-bottom: 1px solid rgba(221,221,221,0.3); } -.bs-docs-social-buttons { - margin-left: 0; + +@media (min-width: 768px) { + + .bs-social { + text-align: left; + } + +} + +.nav, .pagination, .carousel, .panel-title a { + cursor: pointer; +} + +.bs-social-buttons { + display: inline-block; margin-bottom: 0; padding-left: 0; list-style: none; } -.bs-docs-social-buttons li { +.bs-social-buttons li { display: inline-block; padding: 5px 8px; line-height: 1; } -.icon-github { - background: no-repeat url('https://github.com/github-16px.png'); - width: 16px; - height: 16px; +@media (max-width: 767px) { + + .visible-xs.collapse.in { + display: block!important; + } + .visible-xs.collapse { + display: none!important; + } + +} + +.navbar-fixed-top .collapse { + border-top: 1px solid #e7e7e7; + margin-left: -15px; + margin-right: -15px; + padding-right: 15px; + padding-left: 15px; +} + +.show-grid { + margin-bottom: 15px; +} + +/* + * Container + * + * Tweak to width of container. + */ + +/*@media (min-width: 1200px) { + .container{ + max-width: 970px; + } +}*/ + +/* + * Tabs + * + * Tweaks to the Tabs. + */ + +.code .nav-tabs { +border-bottom: 1px solid #ccc; +} + +.code pre, .code code { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } -/* Not enough room on mobile for markup tab, js tab, and plunk btn. +.code .nav-tabs>li.active>a, .code .nav-tabs>li.active>a:hover, .code .nav-tabs>li.active>a:focus { +background-color: #f8f8f8; +border: 1px solid #ccc; +border-bottom-color: transparent; +} + +/* + * Button Inverse + * + * Buttons in the masthead. + */ + +.btn-outline-inverse { +color: #fff; +background-color: transparent; +border-color: #cdbfe3; +margin: 10px; +} + +@media (min-width: 768px) { + + .btn-outline-inverse { + width: auto; + margin: 20px 5px 20px 0; + padding: 18px 24px; + font-size: 21px; + } + +} + +.btn-outline-inverse:hover, .btn-outline-inverse:focus, .btn-outline-inverse:active { +color: #563d7c; +text-shadow: none; +background-color: #fff; +border-color: #fff; +} + + +/* Page headers */ +.bs-header { + padding: 30px 15px 40px; /* side padding builds on .container 15px, so 30px */ + font-size: 16px; + text-align: center; + text-shadow: 0 1px 0 rgba(0,0,0,.15); + color: #cdbfe3; + background-color: #563d7c; + background-image: url(https://github.com/header.png); +} +.bs-header a { + color: #fff; + font-weight: normal; +} +.bs-header h1 { + color: #fff; +} +.bs-header p { + font-weight: 200; + line-height: 1.4; +} +.bs-header .container { + position: relative; +} + +@media (min-width: 768px) { + .bs-header { + font-size: 30px; + text-align: left; + } + .bs-header h1 { + font-size: 100px; + line-height: 1; + } +} + +@media (min-width: 992px) { + .bs-header p { + margin-right: 25%; + } +} + +.navbar-inner { + -webkit-box-shadow: 0 3px 3px rgba(0,0,0,0.175); + box-shadow: 0 3px 3px rgba(0,0,0,0.175); +} + +/* + * Side navigation + * + * Scrollspy and affixed enhanced navigation to highlight sections and secondary + * sections of docs content. + */ + +/* By default it's not affixed in mobile views, so undo that */ +.bs-sidebar.affix { + position: static; +} + +/* First level of nav */ +.bs-sidenav { + margin-top: 30px; + margin-bottom: 30px; + padding-top: 10px; + padding-bottom: 10px; + text-shadow: 0 1px 0 #fff; + background-color: #f7f5fa; + border-radius: 5px; +} + +/* All levels of nav */ +.bs-sidebar .nav > li > a { + display: block; + color: #716b7a; + padding: 5px 20px; +} +.bs-sidebar .nav > li > a:hover, +.bs-sidebar .nav > li > a:focus { + text-decoration: none; + background-color: #e5e3e9; + border-right: 1px solid #dbd8e0; +} +.bs-sidebar .nav > .active > a, +.bs-sidebar .nav > .active:hover > a, +.bs-sidebar .nav > .active:focus > a { + font-weight: bold; + color: #563d7c; + background-color: transparent; + border-right: 1px solid #563d7c; +} + +/* Nav: second level (shown on .active) */ +.bs-sidebar .nav .nav { + display: none; /* Hide by default, but at >768px, show it */ + margin-bottom: 8px; +} +.bs-sidebar .nav .nav > li > a { + padding-top: 3px; + padding-bottom: 3px; + padding-left: 30px; + font-size: 90%; +} + +/* Show and affix the side nav when space allows it */ +@media (min-width: 992px) { + .bs-sidebar .nav > .active > ul { + display: block; + } + /* Widen the fixed sidebar */ + .bs-sidebar.affix, + .bs-sidebar.affix-bottom { + width: 213px; + } + .bs-sidebar.affix { + position: fixed; /* Undo the static from mobile first approach */ + top: 80px; + } + .bs-sidebar.affix-bottom { + position: absolute; /* Undo the static from mobile first approach */ + } + .bs-sidebar.affix-bottom .bs-sidenav, + .bs-sidebar.affix .bs-sidenav { + margin-top: 0; + margin-bottom: 0; + } +} +@media (min-width: 1200px) { + /* Widen the fixed sidebar again */ + .bs-sidebar.affix-bottom, + .bs-sidebar.affix { + width: 263px; + } +} + + +/* Not enough room on mobile for markup tab, js tab, and plunk btn. And no one cares about plunk button on a phone anyway */ @media only screen and (max-device-width: 480px) { #plunk-btn { display: none; } -} \ No newline at end of file +} + +.navbar-nav .dropdown .navbar-brand { + max-width: 100%; + margin-right: inherit; + margin-left: inherit; +} + +.header-placeholder { + height: 50px; +} + +@media screen and (min-width: 768px) { + + .dropdown.open > .navbar-brand + .dropdown-menu { + left: 10px; + } + + .header-placeholder { + height: 50px; + } + + .navbar-nav .dropdown .navbar-brand { + max-width: 200px; + margin-right: 5px; + margin-left: 10px; + } + +} diff --git a/misc/demo/assets/favicon.ico b/misc/demo/assets/favicon.ico new file mode 100644 index 0000000000..147f4de37e Binary files /dev/null and b/misc/demo/assets/favicon.ico differ diff --git a/misc/demo/assets/header.png b/misc/demo/assets/header.png new file mode 100644 index 0000000000..785dd104d3 Binary files /dev/null and b/misc/demo/assets/header.png differ diff --git a/misc/demo/assets/plunker.js b/misc/demo/assets/plunker.js index 6f0d5c8823..f1bd5487b8 100644 --- a/misc/demo/assets/plunker.js +++ b/misc/demo/assets/plunker.js @@ -4,7 +4,7 @@ angular.module('plunker', []) return function (ngVersion, bsVersion, version, module, content) { - var form = angular.element(''); + var form = angular.element(''); var addField = function (name, value) { var input = angular.element(''); input.attr('value', value); @@ -13,12 +13,14 @@ angular.module('plunker', []) var indexContent = function (content, version) { return '\n' + - '\n' + + '\n' + ' \n' + - ' \n' + - ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + ' \n' + - ' \n' + + ' \n' + ' \n' + ' \n\n' + content + '\n' + @@ -27,7 +29,7 @@ angular.module('plunker', []) }; var scriptContent = function(content) { - return "angular.module('plunker', ['ui.bootstrap']);" + "\n" + content; + return "angular.module('ui.bootstrap.demo', ['ngAnimate', 'ngSanitize', 'ui.bootstrap']);" + "\n" + content; }; addField('description', 'http://angular-ui.github.io/bootstrap/'); diff --git a/misc/demo/assets/rainbow.css b/misc/demo/assets/rainbow.css index 088f065754..0a82f6597b 100644 --- a/misc/demo/assets/rainbow.css +++ b/misc/demo/assets/rainbow.css @@ -14,18 +14,18 @@ pre { code { border: 1px solid #eaeaea; - margin: 0px 2px; - padding: 0px 5px; + margin: 0 2px; + padding: 0 5px; font-size: 12px; } pre code { - border: 0px; - padding: 0px; - margin: 0px; - -moz-border-radius: 0px; - -webkit-border-radius: 0px; - border-radius: 0px; + border: 0; + padding: 0; + margin: 0; + -moz-border-radius: 0; + -webkit-border-radius: 0; + border-radius: 0; } pre, code { diff --git a/misc/demo/assets/rainbow.js b/misc/demo/assets/rainbow.js index ed8894d258..e27d9d96c1 100644 --- a/misc/demo/assets/rainbow.js +++ b/misc/demo/assets/rainbow.js @@ -311,9 +311,9 @@ window['Rainbow'] = (function() { _processPattern(regex, pattern, code, callback); }; - // every 100 items we process let's call set timeout + // every 50 items we process let's call set timeout // to let the ui breathe a little - return match_counter % 100 > 0 ? nextCall() : setTimeout(nextCall, 0); + return match_counter % 50 > 0 ? nextCall() : setTimeout(nextCall, 0); }; // if this is not a child match and it falls inside of another diff --git a/misc/demo/assets/select2.css b/misc/demo/assets/select2.css deleted file mode 100644 index d5aa2808c4..0000000000 --- a/misc/demo/assets/select2.css +++ /dev/null @@ -1,524 +0,0 @@ -/* -Version: 3.2 Timestamp: Mon Sep 10 10:38:04 PDT 2012 -*/ -.select2-container { - position: relative; - display: inline-block; - /* inline-block for ie7 */ - zoom: 1; - *display: inline; - vertical-align: top; -} - -.select2-container, -.select2-drop, -.select2-search, -.select2-search input{ - /* - Force border-box so that % widths fit the parent - container without overlap because of margin/padding. - - More Info : http://www.quirksmode.org/css/box.html - */ - -moz-box-sizing: border-box; /* firefox */ - -ms-box-sizing: border-box; /* ie */ - -webkit-box-sizing: border-box; /* webkit */ - -khtml-box-sizing: border-box; /* konqueror */ - box-sizing: border-box; /* css3 */ -} - -.select2-container .select2-choice { - background-color: #fff; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eeeeee), color-stop(0.5, white)); - background-image: -webkit-linear-gradient(center bottom, #eeeeee 0%, white 50%); - background-image: -moz-linear-gradient(center bottom, #eeeeee 0%, white 50%); - background-image: -o-linear-gradient(bottom, #eeeeee 0%, #ffffff 50%); - background-image: -ms-linear-gradient(top, #eeeeee 0%, #ffffff 50%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#eeeeee', endColorstr = '#ffffff', GradientType = 0); - background-image: linear-gradient(top, #eeeeee 0%, #ffffff 50%); - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - -moz-background-clip: padding; - -webkit-background-clip: padding-box; - background-clip: padding-box; - border: 1px solid #aaa; - display: block; - overflow: hidden; - white-space: nowrap; - position: relative; - height: 26px; - line-height: 26px; - padding: 0 0 0 8px; - color: #444; - text-decoration: none; -} - -.select2-container.select2-drop-above .select2-choice -{ - border-bottom-color: #aaa; - -webkit-border-radius:0px 0px 4px 4px; - -moz-border-radius:0px 0px 4px 4px; - border-radius:0px 0px 4px 4px; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eeeeee), color-stop(0.9, white)); - background-image: -webkit-linear-gradient(center bottom, #eeeeee 0%, white 90%); - background-image: -moz-linear-gradient(center bottom, #eeeeee 0%, white 90%); - background-image: -o-linear-gradient(bottom, #eeeeee 0%, white 90%); - background-image: -ms-linear-gradient(top, #eeeeee 0%,#ffffff 90%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#ffffff',GradientType=0 ); - background-image: linear-gradient(top, #eeeeee 0%,#ffffff 90%); -} - -.select2-container .select2-choice span { - margin-right: 26px; - display: block; - overflow: hidden; - white-space: nowrap; - -o-text-overflow: ellipsis; - -ms-text-overflow: ellipsis; - text-overflow: ellipsis; -} - -.select2-container .select2-choice abbr { - display: block; - position: absolute; - right: 26px; - top: 8px; - width: 12px; - height: 12px; - font-size: 1px; - background: url('https://github.com/select2.png') right top no-repeat; - cursor: pointer; - text-decoration: none; - border:0; - outline: 0; -} -.select2-container .select2-choice abbr:hover { - background-position: right -11px; - cursor: pointer; -} - -.select2-drop { - background: #fff; - color: #000; - border: 1px solid #aaa; - border-top: 0; - position: absolute; - top: 100%; - -webkit-box-shadow: 0 4px 5px rgba(0, 0, 0, .15); - -moz-box-shadow: 0 4px 5px rgba(0, 0, 0, .15); - -o-box-shadow: 0 4px 5px rgba(0, 0, 0, .15); - box-shadow: 0 4px 5px rgba(0, 0, 0, .15); - z-index: 9999; - width:100%; - margin-top:-1px; - - -webkit-border-radius: 0 0 4px 4px; - -moz-border-radius: 0 0 4px 4px; - border-radius: 0 0 4px 4px; -} - -.select2-drop.select2-drop-above { - -webkit-border-radius: 4px 4px 0px 0px; - -moz-border-radius: 4px 4px 0px 0px; - border-radius: 4px 4px 0px 0px; - margin-top:1px; - border-top: 1px solid #aaa; - border-bottom: 0; - - -webkit-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15); - -moz-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15); - -o-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15); - box-shadow: 0 -4px 5px rgba(0, 0, 0, .15); -} - -.select2-container .select2-choice div { - -webkit-border-radius: 0 4px 4px 0; - -moz-border-radius: 0 4px 4px 0; - border-radius: 0 4px 4px 0; - -moz-background-clip: padding; - -webkit-background-clip: padding-box; - background-clip: padding-box; - background: #ccc; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #ccc), color-stop(0.6, #eee)); - background-image: -webkit-linear-gradient(center bottom, #ccc 0%, #eee 60%); - background-image: -moz-linear-gradient(center bottom, #ccc 0%, #eee 60%); - background-image: -o-linear-gradient(bottom, #ccc 0%, #eee 60%); - background-image: -ms-linear-gradient(top, #cccccc 0%, #eeeeee 60%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#cccccc', endColorstr = '#eeeeee', GradientType = 0); - background-image: linear-gradient(top, #cccccc 0%, #eeeeee 60%); - border-left: 1px solid #aaa; - position: absolute; - right: 0; - top: 0; - display: block; - height: 100%; - width: 18px; -} - -.select2-container .select2-choice div b { - background: url('https://github.com/select2.png') no-repeat 0 1px; - display: block; - width: 100%; - height: 100%; -} - -.select2-search { - display: inline-block; - white-space: nowrap; - z-index: 10000; - min-height: 26px; - width: 100%; - margin: 0; - padding-left: 4px; - padding-right: 4px; -} - -.select2-search-hidden { - display: block; - position: absolute; - left: -10000px; -} - -.select2-search input { - background: #fff url('https://github.com/select2.png') no-repeat 100% -22px; - background: url('https://github.com/select2.png') no-repeat 100% -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee)); - background: url('https://github.com/select2.png') no-repeat 100% -22px, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%); - background: url('https://github.com/select2.png') no-repeat 100% -22px, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%); - background: url('https://github.com/select2.png') no-repeat 100% -22px, -o-linear-gradient(bottom, white 85%, #eeeeee 99%); - background: url('https://github.com/select2.png') no-repeat 100% -22px, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%); - background: url('https://github.com/select2.png') no-repeat 100% -22px, linear-gradient(top, #ffffff 85%, #eeeeee 99%); - padding: 4px 20px 4px 5px; - outline: 0; - border: 1px solid #aaa; - font-family: sans-serif; - font-size: 1em; - width:100%; - margin:0; - height:auto !important; - min-height: 26px; - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; - border-radius: 0; - -moz-border-radius: 0; - -webkit-border-radius: 0; -} - -.select2-drop.select2-drop-above .select2-search input -{ - margin-top:4px; -} - -.select2-search input.select2-active { - background: #fff url('https://github.com/spinner.gif') no-repeat 100%; - background: url('https://github.com/spinner.gif') no-repeat 100%, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee)); - background: url('https://github.com/spinner.gif') no-repeat 100%, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%); - background: url('https://github.com/spinner.gif') no-repeat 100%, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%); - background: url('https://github.com/spinner.gif') no-repeat 100%, -o-linear-gradient(bottom, white 85%, #eeeeee 99%); - background: url('https://github.com/spinner.gif') no-repeat 100%, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%); - background: url('https://github.com/spinner.gif') no-repeat 100%, linear-gradient(top, #ffffff 85%, #eeeeee 99%); -} - - -.select2-container-active .select2-choice, -.select2-container-active .select2-choices { - -webkit-box-shadow: 0 0 5px rgba(0,0,0,.3); - -moz-box-shadow : 0 0 5px rgba(0,0,0,.3); - -o-box-shadow : 0 0 5px rgba(0,0,0,.3); - box-shadow : 0 0 5px rgba(0,0,0,.3); - border: 1px solid #5897fb; - outline: none; -} - -.select2-dropdown-open .select2-choice { - border: 1px solid #aaa; - border-bottom-color: transparent; - -webkit-box-shadow: 0 1px 0 #fff inset; - -moz-box-shadow : 0 1px 0 #fff inset; - -o-box-shadow : 0 1px 0 #fff inset; - box-shadow : 0 1px 0 #fff inset; - background-color: #eee; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, white), color-stop(0.5, #eeeeee)); - background-image: -webkit-linear-gradient(center bottom, white 0%, #eeeeee 50%); - background-image: -moz-linear-gradient(center bottom, white 0%, #eeeeee 50%); - background-image: -o-linear-gradient(bottom, white 0%, #eeeeee 50%); - background-image: -ms-linear-gradient(top, #ffffff 0%,#eeeeee 50%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffff', endColorstr='#eeeeee',GradientType=0 ); - background-image: linear-gradient(top, #ffffff 0%,#eeeeee 50%); - -webkit-border-bottom-left-radius : 0; - -webkit-border-bottom-right-radius: 0; - -moz-border-radius-bottomleft : 0; - -moz-border-radius-bottomright: 0; - border-bottom-left-radius : 0; - border-bottom-right-radius: 0; -} - -.select2-dropdown-open .select2-choice div { - background: transparent; - border-left: none; -} -.select2-dropdown-open .select2-choice div b { - background-position: -18px 1px; -} - -/* results */ -.select2-results { - margin: 4px 4px 4px 0; - padding: 0 0 0 4px; - position: relative; - overflow-x: hidden; - overflow-y: auto; - max-height: 200px; -} - -.select2-results ul.select2-result-sub { - margin: 0 0 0 0; -} - -.select2-results ul.select2-result-sub > li .select2-result-label { padding-left: 20px } -.select2-results ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 40px } -.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 60px } -.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 80px } -.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 100px } -.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 110px } -.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 120px } - -.select2-results li { - list-style: none; - display: list-item; -} - -.select2-results li.select2-result-with-children > .select2-result-label { - font-weight: bold; -} - -.select2-results .select2-result-label { - padding: 3px 7px 4px; - margin: 0; - cursor: pointer; -} - -.select2-results .select2-highlighted { - background: #3875d7; - color: #fff; -} -.select2-results li em { - background: #feffde; - font-style: normal; -} -.select2-results .select2-highlighted em { - background: transparent; -} -.select2-results .select2-no-results, -.select2-results .select2-searching, -.select2-results .select2-selection-limit { - background: #f4f4f4; - display: list-item; -} - -/* -disabled look for already selected choices in the results dropdown -.select2-results .select2-disabled.select2-highlighted { - color: #666; - background: #f4f4f4; - display: list-item; - cursor: default; -} -.select2-results .select2-disabled { - background: #f4f4f4; - display: list-item; - cursor: default; -} -*/ -.select2-results .select2-disabled { - display: none; -} - -.select2-more-results.select2-active { - background: #f4f4f4 url('https://github.com/spinner.gif') no-repeat 100%; -} - -.select2-more-results { - background: #f4f4f4; - display: list-item; -} - -/* disabled styles */ - -.select2-container.select2-container-disabled .select2-choice { - background-color: #f4f4f4; - background-image: none; - border: 1px solid #ddd; - cursor: default; -} - -.select2-container.select2-container-disabled .select2-choice div { - background-color: #f4f4f4; - background-image: none; - border-left: 0; -} - - -/* multiselect */ - -.select2-container-multi .select2-choices { - background-color: #fff; - background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(1%, #eeeeee), color-stop(15%, #ffffff)); - background-image: -webkit-linear-gradient(top, #eeeeee 1%, #ffffff 15%); - background-image: -moz-linear-gradient(top, #eeeeee 1%, #ffffff 15%); - background-image: -o-linear-gradient(top, #eeeeee 1%, #ffffff 15%); - background-image: -ms-linear-gradient(top, #eeeeee 1%, #ffffff 15%); - background-image: linear-gradient(top, #eeeeee 1%, #ffffff 15%); - border: 1px solid #aaa; - margin: 0; - padding: 0; - cursor: text; - overflow: hidden; - height: auto !important; - height: 1%; - position: relative; -} - -.select2-container-multi .select2-choices { - min-height: 26px; -} - -.select2-container-multi.select2-container-active .select2-choices { - -webkit-box-shadow: 0 0 5px rgba(0,0,0,.3); - -moz-box-shadow : 0 0 5px rgba(0,0,0,.3); - -o-box-shadow : 0 0 5px rgba(0,0,0,.3); - box-shadow : 0 0 5px rgba(0,0,0,.3); - border: 1px solid #5897fb; - outline: none; -} -.select2-container-multi .select2-choices li { - float: left; - list-style: none; -} -.select2-container-multi .select2-choices .select2-search-field { - white-space: nowrap; - margin: 0; - padding: 0; -} - -.select2-container-multi .select2-choices .select2-search-field input { - color: #666; - background: transparent !important; - font-family: sans-serif; - font-size: 100%; - height: 15px; - padding: 5px; - margin: 1px 0; - outline: 0; - border: 0; - -webkit-box-shadow: none; - -moz-box-shadow : none; - -o-box-shadow : none; - box-shadow : none; -} - -.select2-container-multi .select2-choices .select2-search-field input.select2-active { - background: #fff url('https://github.com/spinner.gif') no-repeat 100% !important; -} - -.select2-default { - color: #999 !important; -} - -.select2-container-multi .select2-choices .select2-search-choice { - -webkit-border-radius: 3px; - -moz-border-radius : 3px; - border-radius : 3px; - -moz-background-clip : padding; - -webkit-background-clip: padding-box; - background-clip : padding-box; - background-color: #e4e4e4; - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f4f4f4', endColorstr='#eeeeee', GradientType=0 ); - background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(20%, #f4f4f4), color-stop(50%, #f0f0f0), color-stop(52%, #e8e8e8), color-stop(100%, #eeeeee)); - background-image: -webkit-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); - background-image: -moz-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); - background-image: -o-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); - background-image: -ms-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); - background-image: linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); - -webkit-box-shadow: 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); - -moz-box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); - box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); - color: #333; - border: 1px solid #aaaaaa; - line-height: 13px; - padding: 3px 5px 3px 18px; - margin: 3px 0 3px 5px; - position: relative; - cursor: default; -} -.select2-container-multi .select2-choices .select2-search-choice span { - cursor: default; -} -.select2-container-multi .select2-choices .select2-search-choice-focus { - background: #d4d4d4; -} - -.select2-search-choice-close { - display: block; - position: absolute; - right: 3px; - top: 4px; - width: 12px; - height: 13px; - font-size: 1px; - background: url('https://github.com/select2.png') right top no-repeat; - outline: none; -} - -.select2-container-multi .select2-search-choice-close { - left: 3px; -} - - -.select2-container-multi .select2-choices .select2-search-choice .select2-search-choice-close:hover { - background-position: right -11px; -} -.select2-container-multi .select2-choices .select2-search-choice-focus .select2-search-choice-close { - background-position: right -11px; -} - -/* disabled styles */ - -.select2-container-multi.select2-container-disabled .select2-choices{ - background-color: #f4f4f4; - background-image: none; - border: 1px solid #ddd; - cursor: default; -} - -.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice { - background-image: none; - background-color: #f4f4f4; - border: 1px solid #ddd; - padding: 3px 5px 3px 5px; -} - -.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close { - display: none; -} -/* end multiselect */ - -.select2-result-selectable .select2-match, -.select2-result-unselectable .select2-result-selectable .select2-match { text-decoration: underline; } -.select2-result-unselectable .select2-match { text-decoration: none; } - -.select2-offscreen { position: absolute; left: -10000px; } - -/* Retina-ize icons */ - -@media only screen and (-webkit-min-device-pixel-ratio: 1.5) { - .select2-search input, .select2-search-choice-close, .select2-container .select2-choice abbr, .select2-container .select2-choice div b { - background-image: url(https://github.com/select2x2.png) !important; - background-repeat: no-repeat !important; - background-size: 60px 40px !important; - } - .select2-search input { - background-position: 100% -21px !important; - } -} diff --git a/misc/demo/assets/select2.js b/misc/demo/assets/select2.js deleted file mode 100644 index 213f4cf930..0000000000 --- a/misc/demo/assets/select2.js +++ /dev/null @@ -1,2407 +0,0 @@ -/* - Copyright 2012 Igor Vaynberg - - Version: 3.2 Timestamp: Mon Sep 10 10:38:04 PDT 2012 - - Licensed under the Apache License, Version 2.0 (the "License"); you may not use this work except in - compliance with the License. You may obtain a copy of the License in the LICENSE file, or at: - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software distributed under the License is - distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and limitations under the License. - */ - (function ($) { - if(typeof $.fn.each2 == "undefined"){ - $.fn.extend({ - /* - * 4-10 times faster .each replacement - * use it carefully, as it overrides jQuery context of element on each iteration - */ - each2 : function (c) { - var j = $([0]), i = -1, l = this.length; - while ( - ++i < l - && (j.context = j[0] = this[i]) - && c.call(j[0], i, j) !== false //"this"=DOM, i=index, j=jQuery object - ); - return this; - } - }); - } -})(jQuery); - -(function ($, undefined) { - "use strict"; - /*global document, window, jQuery, console */ - - if (window.Select2 !== undefined) { - return; - } - - var KEY, AbstractSelect2, SingleSelect2, MultiSelect2, nextUid, sizer; - - KEY = { - TAB: 9, - ENTER: 13, - ESC: 27, - SPACE: 32, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - SHIFT: 16, - CTRL: 17, - ALT: 18, - PAGE_UP: 33, - PAGE_DOWN: 34, - HOME: 36, - END: 35, - BACKSPACE: 8, - DELETE: 46, - isArrow: function (k) { - k = k.which ? k.which : k; - switch (k) { - case KEY.LEFT: - case KEY.RIGHT: - case KEY.UP: - case KEY.DOWN: - return true; - } - return false; - }, - isControl: function (e) { - var k = e.which; - switch (k) { - case KEY.SHIFT: - case KEY.CTRL: - case KEY.ALT: - return true; - } - - if (e.metaKey) return true; - - return false; - }, - isFunctionKey: function (k) { - k = k.which ? k.which : k; - return k >= 112 && k <= 123; - } - }; - - nextUid=(function() { var counter=1; return function() { return counter++; }; }()); - - function indexOf(value, array) { - var i = 0, l = array.length, v; - - if (typeof value === "undefined") { - return -1; - } - - if (value.constructor === String) { - for (; i < l; i = i + 1) if (value.localeCompare(array[i]) === 0) return i; - } else { - for (; i < l; i = i + 1) { - v = array[i]; - if (v.constructor === String) { - if (v.localeCompare(value) === 0) return i; - } else { - if (v === value) return i; - } - } - } - return -1; - } - - /** - * Compares equality of a and b taking into account that a and b may be strings, in which case localeCompare is used - * @param a - * @param b - */ - function equal(a, b) { - if (a === b) return true; - if (a === undefined || b === undefined) return false; - if (a === null || b === null) return false; - if (a.constructor === String) return a.localeCompare(b) === 0; - if (b.constructor === String) return b.localeCompare(a) === 0; - return false; - } - - /** - * Splits the string into an array of values, trimming each value. An empty array is returned for nulls or empty - * strings - * @param string - * @param separator - */ - function splitVal(string, separator) { - var val, i, l; - if (string === null || string.length < 1) return []; - val = string.split(separator); - for (i = 0, l = val.length; i < l; i = i + 1) val[i] = $.trim(val[i]); - return val; - } - - function getSideBorderPadding(element) { - return element.outerWidth() - element.width(); - } - - function installKeyUpChangeEvent(element) { - var key="keyup-change-value"; - element.bind("keydown", function () { - if ($.data(element, key) === undefined) { - $.data(element, key, element.val()); - } - }); - element.bind("keyup", function () { - var val= $.data(element, key); - if (val !== undefined && element.val() !== val) { - $.removeData(element, key); - element.trigger("keyup-change"); - } - }); - } - - $(document).delegate("body", "mousemove", function (e) { - $.data(document, "select2-lastpos", {x: e.pageX, y: e.pageY}); - }); - - /** - * filters mouse events so an event is fired only if the mouse moved. - * - * filters out mouse events that occur when mouse is stationary but - * the elements under the pointer are scrolled. - */ - function installFilteredMouseMove(element) { - element.bind("mousemove", function (e) { - var lastpos = $.data(document, "select2-lastpos"); - if (lastpos === undefined || lastpos.x !== e.pageX || lastpos.y !== e.pageY) { - $(e.target).trigger("mousemove-filtered", e); - } - }); - } - - /** - * Debounces a function. Returns a function that calls the original fn function only if no invocations have been made - * within the last quietMillis milliseconds. - * - * @param quietMillis number of milliseconds to wait before invoking fn - * @param fn function to be debounced - * @param ctx object to be used as this reference within fn - * @return debounced version of fn - */ - function debounce(quietMillis, fn, ctx) { - ctx = ctx || undefined; - var timeout; - return function () { - var args = arguments; - window.clearTimeout(timeout); - timeout = window.setTimeout(function() { - fn.apply(ctx, args); - }, quietMillis); - }; - } - - /** - * A simple implementation of a thunk - * @param formula function used to lazily initialize the thunk - * @return {Function} - */ - function thunk(formula) { - var evaluated = false, - value; - return function() { - if (evaluated === false) { value = formula(); evaluated = true; } - return value; - }; - }; - - function installDebouncedScroll(threshold, element) { - var notify = debounce(threshold, function (e) { element.trigger("scroll-debounced", e);}); - element.bind("scroll", function (e) { - if (indexOf(e.target, element.get()) >= 0) notify(e); - }); - } - - function killEvent(event) { - event.preventDefault(); - event.stopPropagation(); - } - - function measureTextWidth(e) { - if (!sizer){ - var style = e[0].currentStyle || window.getComputedStyle(e[0], null); - sizer = $("").css({ - position: "absolute", - left: "-10000px", - top: "-10000px", - display: "none", - fontSize: style.fontSize, - fontFamily: style.fontFamily, - fontStyle: style.fontStyle, - fontWeight: style.fontWeight, - letterSpacing: style.letterSpacing, - textTransform: style.textTransform, - whiteSpace: "nowrap" - }); - $("body").append(sizer); - } - sizer.text(e.val()); - return sizer.width(); - } - - function markMatch(text, term, markup) { - var match=text.toUpperCase().indexOf(term.toUpperCase()), - tl=term.length; - - if (match<0) { - markup.push(text); - return; - } - - markup.push(text.substring(0, match)); - markup.push(""); - markup.push(text.substring(match, match + tl)); - markup.push(""); - markup.push(text.substring(match + tl, text.length)); - } - - /** - * Produces an ajax-based query function - * - * @param options object containing configuration paramters - * @param options.transport function that will be used to execute the ajax request. must be compatible with parameters supported by $.ajax - * @param options.url url for the data - * @param options.data a function(searchTerm, pageNumber, context) that should return an object containing query string parameters for the above url. - * @param options.dataType request data type: ajax, jsonp, other datatatypes supported by jQuery's $.ajax function or the transport function if specified - * @param options.traditional a boolean flag that should be true if you wish to use the traditional style of param serialization for the ajax request - * @param options.quietMillis (optional) milliseconds to wait before making the ajaxRequest, helps debounce the ajax function if invoked too often - * @param options.results a function(remoteData, pageNumber) that converts data returned form the remote request to the format expected by Select2. - * The expected format is an object containing the following keys: - * results array of objects that will be used as choices - * more (optional) boolean indicating whether there are more results available - * Example: {results:[{id:1, text:'Red'},{id:2, text:'Blue'}], more:true} - */ - function ajax(options) { - var timeout, // current scheduled but not yet executed request - requestSequence = 0, // sequence used to drop out-of-order responses - handler = null, - quietMillis = options.quietMillis || 100; - - return function (query) { - window.clearTimeout(timeout); - timeout = window.setTimeout(function () { - requestSequence += 1; // increment the sequence - var requestNumber = requestSequence, // this request's sequence number - data = options.data, // ajax data function - transport = options.transport || $.ajax, - traditional = options.traditional || false, - type = options.type || 'GET'; // set type of request (GET or POST) - - data = data.call(this, query.term, query.page, query.context); - - if( null !== handler) { handler.abort(); } - - handler = transport.call(null, { - url: options.url, - dataType: options.dataType, - data: data, - type: type, - traditional: traditional, - success: function (data) { - if (requestNumber < requestSequence) { - return; - } - // TODO 3.0 - replace query.page with query so users have access to term, page, etc. - var results = options.results(data, query.page); - query.callback(results); - } - }); - }, quietMillis); - }; - } - - /** - * Produces a query function that works with a local array - * - * @param options object containing configuration parameters. The options parameter can either be an array or an - * object. - * - * If the array form is used it is assumed that it contains objects with 'id' and 'text' keys. - * - * If the object form is used ti is assumed that it contains 'data' and 'text' keys. The 'data' key should contain - * an array of objects that will be used as choices. These objects must contain at least an 'id' key. The 'text' - * key can either be a String in which case it is expected that each element in the 'data' array has a key with the - * value of 'text' which will be used to match choices. Alternatively, text can be a function(item) that can extract - * the text. - */ - function local(options) { - var data = options, // data elements - dataText, - text = function (item) { return ""+item.text; }; // function used to retrieve the text portion of a data item that is matched against the search - - if (!$.isArray(data)) { - text = data.text; - // if text is not a function we assume it to be a key name - if (!$.isFunction(text)) { - dataText = data.text; // we need to store this in a separate variable because in the next step data gets reset and data.text is no longer available - text = function (item) { return item[dataText]; }; - } - data = data.results; - } - - return function (query) { - var t = query.term, filtered = { results: [] }, process; - if (t === "") { - query.callback({results: data}); - return; - } - - process = function(datum, collection) { - var group, attr; - datum = datum[0]; - if (datum.children) { - group = {}; - for (attr in datum) { - if (datum.hasOwnProperty(attr)) group[attr]=datum[attr]; - } - group.children=[]; - $(datum.children).each2(function(i, childDatum) { process(childDatum, group.children); }); - if (group.children.length) { - collection.push(group); - } - } else { - if (query.matcher(t, text(datum))) { - collection.push(datum); - } - } - }; - - $(data).each2(function(i, datum) { process(datum, filtered.results); }); - query.callback(filtered); - }; - } - - // TODO javadoc - function tags(data) { - // TODO even for a function we should probably return a wrapper that does the same object/string check as - // the function for arrays. otherwise only functions that return objects are supported. - if ($.isFunction(data)) { - return data; - } - - // if not a function we assume it to be an array - - return function (query) { - var t = query.term, filtered = {results: []}; - $(data).each(function () { - var isObject = this.text !== undefined, - text = isObject ? this.text : this; - if (t === "" || query.matcher(t, text)) { - filtered.results.push(isObject ? this : {id: this, text: this}); - } - }); - query.callback(filtered); - }; - } - - /** - * Checks if the formatter function should be used. - * - * Throws an error if it is not a function. Returns true if it should be used, - * false if no formatting should be performed. - * - * @param formatter - */ - function checkFormatter(formatter, formatterName) { - if ($.isFunction(formatter)) return true; - if (!formatter) return false; - throw new Error("formatterName must be a function or a falsy value"); - } - - function evaluate(val) { - return $.isFunction(val) ? val() : val; - } - - function countResults(results) { - var count = 0; - $.each(results, function(i, item) { - if (item.children) { - count += countResults(item.children); - } else { - count++; - } - }); - return count; - } - - /** - * Default tokenizer. This function uses breaks the input on substring match of any string from the - * opts.tokenSeparators array and uses opts.createSearchChoice to create the choice object. Both of those - * two options have to be defined in order for the tokenizer to work. - * - * @param input text user has typed so far or pasted into the search field - * @param selection currently selected choices - * @param selectCallback function(choice) callback tho add the choice to selection - * @param opts select2's opts - * @return undefined/null to leave the current input unchanged, or a string to change the input to the returned value - */ - function defaultTokenizer(input, selection, selectCallback, opts) { - var original = input, // store the original so we can compare and know if we need to tell the search to update its text - dupe = false, // check for whether a token we extracted represents a duplicate selected choice - token, // token - index, // position at which the separator was found - i, l, // looping variables - separator; // the matched separator - - if (!opts.createSearchChoice || !opts.tokenSeparators || opts.tokenSeparators.length < 1) return undefined; - - while (true) { - index = -1; - - for (i = 0, l = opts.tokenSeparators.length; i < l; i++) { - separator = opts.tokenSeparators[i]; - index = input.indexOf(separator); - if (index >= 0) break; - } - - if (index < 0) break; // did not find any token separator in the input string, bail - - token = input.substring(0, index); - input = input.substring(index + separator.length); - - if (token.length > 0) { - token = opts.createSearchChoice(token, selection); - if (token !== undefined && token !== null && opts.id(token) !== undefined && opts.id(token) !== null) { - dupe = false; - for (i = 0, l = selection.length; i < l; i++) { - if (equal(opts.id(token), opts.id(selection[i]))) { - dupe = true; break; - } - } - - if (!dupe) selectCallback(token); - } - } - } - - if (original.localeCompare(input) != 0) return input; - } - - /** - * blurs any Select2 container that has focus when an element outside them was clicked or received focus - * - * also takes care of clicks on label tags that point to the source element - */ - $(document).ready(function () { - $(document).delegate("body", "mousedown touchend", function (e) { - var target = $(e.target).closest("div.select2-container").get(0), attr; - if (target) { - $(document).find("div.select2-container-active").each(function () { - if (this !== target) $(this).data("select2").blur(); - }); - } else { - target = $(e.target).closest("div.select2-drop").get(0); - $(document).find("div.select2-drop-active").each(function () { - if (this !== target) $(this).data("select2").blur(); - }); - } - - target=$(e.target); - attr = target.attr("for"); - if ("LABEL" === e.target.tagName && attr && attr.length > 0) { - target = $("#"+attr); - target = target.data("select2"); - if (target !== undefined) { target.focus(); e.preventDefault();} - } - }); - }); - - /** - * Creates a new class - * - * @param superClass - * @param methods - */ - function clazz(SuperClass, methods) { - var constructor = function () {}; - constructor.prototype = new SuperClass; - constructor.prototype.constructor = constructor; - constructor.prototype.parent = SuperClass.prototype; - constructor.prototype = $.extend(constructor.prototype, methods); - return constructor; - } - - AbstractSelect2 = clazz(Object, { - - // abstract - bind: function (func) { - var self = this; - return function () { - func.apply(self, arguments); - }; - }, - - // abstract - init: function (opts) { - var results, search, resultsSelector = ".select2-results"; - - // prepare options - this.opts = opts = this.prepareOpts(opts); - - this.id=opts.id; - - // destroy if called on an existing component - if (opts.element.data("select2") !== undefined && - opts.element.data("select2") !== null) { - this.destroy(); - } - - this.enabled=true; - this.container = this.createContainer(); - - this.containerId="s2id_"+(opts.element.attr("id") || "autogen"+nextUid()); - this.containerSelector="#"+this.containerId.replace(/([;&,\.\+\*\~':"\!\^#$%@\[\]\(\)=>\|])/g, '\\$1'); - this.container.attr("id", this.containerId); - - // cache the body so future lookups are cheap - this.body = thunk(function() { return opts.element.closest("body"); }); - - if (opts.element.attr("class") !== undefined) { - this.container.addClass(opts.element.attr("class").replace(/validate\[[\S ]+] ?/, '')); - } - - this.container.css(evaluate(opts.containerCss)); - this.container.addClass(evaluate(opts.containerCssClass)); - - // swap container for the element - this.opts.element - .data("select2", this) - .hide() - .before(this.container); - this.container.data("select2", this); - - this.dropdown = this.container.find(".select2-drop"); - this.dropdown.addClass(evaluate(opts.dropdownCssClass)); - this.dropdown.data("select2", this); - - this.results = results = this.container.find(resultsSelector); - this.search = search = this.container.find("input.select2-input"); - - search.attr("tabIndex", this.opts.element.attr("tabIndex")); - - this.resultsPage = 0; - this.context = null; - - // initialize the container - this.initContainer(); - this.initContainerWidth(); - - installFilteredMouseMove(this.results); - this.dropdown.delegate(resultsSelector, "mousemove-filtered", this.bind(this.highlightUnderEvent)); - - installDebouncedScroll(80, this.results); - this.dropdown.delegate(resultsSelector, "scroll-debounced", this.bind(this.loadMoreIfNeeded)); - - // if jquery.mousewheel plugin is installed we can prevent out-of-bounds scrolling of results via mousewheel - if ($.fn.mousewheel) { - results.mousewheel(function (e, delta, deltaX, deltaY) { - var top = results.scrollTop(), height; - if (deltaY > 0 && top - deltaY <= 0) { - results.scrollTop(0); - killEvent(e); - } else if (deltaY < 0 && results.get(0).scrollHeight - results.scrollTop() + deltaY <= results.height()) { - results.scrollTop(results.get(0).scrollHeight - results.height()); - killEvent(e); - } - }); - } - - installKeyUpChangeEvent(search); - search.bind("keyup-change", this.bind(this.updateResults)); - search.bind("focus", function () { search.addClass("select2-focused"); if (search.val() === " ") search.val(""); }); - search.bind("blur", function () { search.removeClass("select2-focused");}); - - this.dropdown.delegate(resultsSelector, "mouseup", this.bind(function (e) { - if ($(e.target).closest(".select2-result-selectable:not(.select2-disabled)").length > 0) { - this.highlightUnderEvent(e); - this.selectHighlighted(e); - } else { - this.focusSearch(); - } - killEvent(e); - })); - - // trap all mouse events from leaving the dropdown. sometimes there may be a modal that is listening - // for mouse events outside of itself so it can close itself. since the dropdown is now outside the select2's - // dom it will trigger the popup close, which is not what we want - this.dropdown.bind("click mouseup mousedown", function (e) { e.stopPropagation(); }); - - if ($.isFunction(this.opts.initSelection)) { - // initialize selection based on the current value of the source element - this.initSelection(); - - // if the user has provided a function that can set selection based on the value of the source element - // we monitor the change event on the element and trigger it, allowing for two way synchronization - this.monitorSource(); - } - - if (opts.element.is(":disabled") || opts.element.is("[readonly='readonly']")) this.disable(); - }, - - // abstract - destroy: function () { - var select2 = this.opts.element.data("select2"); - if (select2 !== undefined) { - select2.container.remove(); - select2.dropdown.remove(); - select2.opts.element - .removeData("select2") - .unbind(".select2") - .show(); - } - }, - - // abstract - prepareOpts: function (opts) { - var element, select, idKey, ajaxUrl; - - element = opts.element; - - if (element.get(0).tagName.toLowerCase() === "select") { - this.select = select = opts.element; - } - - if (select) { - // these options are not allowed when attached to a select because they are picked up off the element itself - $.each(["id", "multiple", "ajax", "query", "createSearchChoice", "initSelection", "data", "tags"], function () { - if (this in opts) { - throw new Error("Option '" + this + "' is not allowed for Select2 when attached to a element."); - } - }); - } - - opts = $.extend({}, { - populateResults: function(container, results, query) { - var populate, data, result, children, id=this.opts.id, self=this; - - populate=function(results, container, depth) { - - var i, l, result, selectable, compound, node, label, innerContainer, formatted; - for (i = 0, l = results.length; i < l; i = i + 1) { - - result=results[i]; - selectable=id(result) !== undefined; - compound=result.children && result.children.length > 0; - - node=$(""); - node.addClass("select2-results-dept-"+depth); - node.addClass("select2-result"); - node.addClass(selectable ? "select2-result-selectable" : "select2-result-unselectable"); - if (compound) { node.addClass("select2-result-with-children"); } - node.addClass(self.opts.formatResultCssClass(result)); - - label=$(""); - label.addClass("select2-result-label"); - - formatted=opts.formatResult(result, label, query); - if (formatted!==undefined) { - label.html(self.opts.escapeMarkup(formatted)); - } - - node.append(label); - - if (compound) { - - innerContainer=$(""); - innerContainer.addClass("select2-result-sub"); - populate(result.children, innerContainer, depth+1); - node.append(innerContainer); - } - - node.data("select2-data", result); - container.append(node); - } - }; - - populate(results, container, 0); - } - }, $.fn.select2.defaults, opts); - - if (typeof(opts.id) !== "function") { - idKey = opts.id; - opts.id = function (e) { return e[idKey]; }; - } - - if (select) { - opts.query = this.bind(function (query) { - var data = { results: [], more: false }, - term = query.term, - children, firstChild, process; - - process=function(element, collection) { - var group; - if (element.is("option")) { - if (query.matcher(term, element.text(), element)) { - collection.push({id:element.attr("value"), text:element.text(), element: element.get(), css: element.attr("class")}); - } - } else if (element.is("optgroup")) { - group={text:element.attr("label"), children:[], element: element.get(), css: element.attr("class")}; - element.children().each2(function(i, elm) { process(elm, group.children); }); - if (group.children.length>0) { - collection.push(group); - } - } - }; - - children=element.children(); - - // ignore the placeholder option if there is one - if (this.getPlaceholder() !== undefined && children.length > 0) { - firstChild = children[0]; - if ($(firstChild).text() === "") { - children=children.not(firstChild); - } - } - - children.each2(function(i, elm) { process(elm, data.results); }); - - query.callback(data); - }); - // this is needed because inside val() we construct choices from options and there id is hardcoded - opts.id=function(e) { return e.id; }; - opts.formatResultCssClass = function(data) { return data.css; } - } else { - if (!("query" in opts)) { - if ("ajax" in opts) { - ajaxUrl = opts.element.data("ajax-url"); - if (ajaxUrl && ajaxUrl.length > 0) { - opts.ajax.url = ajaxUrl; - } - opts.query = ajax(opts.ajax); - } else if ("data" in opts) { - opts.query = local(opts.data); - } else if ("tags" in opts) { - opts.query = tags(opts.tags); - opts.createSearchChoice = function (term) { return {id: term, text: term}; }; - opts.initSelection = function (element, callback) { - var data = []; - $(splitVal(element.val(), opts.separator)).each(function () { - var id = this, text = this, tags=opts.tags; - if ($.isFunction(tags)) tags=tags(); - $(tags).each(function() { if (equal(this.id, id)) { text = this.text; return false; } }); - data.push({id: id, text: text}); - }); - - callback(data); - }; - } - } - } - if (typeof(opts.query) !== "function") { - throw "query function not defined for Select2 " + opts.element.attr("id"); - } - - return opts; - }, - - /** - * Monitor the original element for changes and update select2 accordingly - */ - // abstract - monitorSource: function () { - this.opts.element.bind("change.select2", this.bind(function (e) { - if (this.opts.element.data("select2-change-triggered") !== true) { - this.initSelection(); - } - })); - }, - - /** - * Triggers the change event on the source element - */ - // abstract - triggerChange: function (details) { - - details = details || {}; - details= $.extend({}, details, { type: "change", val: this.val() }); - // prevents recursive triggering - this.opts.element.data("select2-change-triggered", true); - this.opts.element.trigger(details); - this.opts.element.data("select2-change-triggered", false); - - // some validation frameworks ignore the change event and listen instead to keyup, click for selects - // so here we trigger the click event manually - this.opts.element.click(); - - // ValidationEngine ignorea the change event and listens instead to blur - // so here we trigger the blur event manually if so desired - if (this.opts.blurOnChange) - this.opts.element.blur(); - }, - - - // abstract - enable: function() { - if (this.enabled) return; - - this.enabled=true; - this.container.removeClass("select2-container-disabled"); - }, - - // abstract - disable: function() { - if (!this.enabled) return; - - this.close(); - - this.enabled=false; - this.container.addClass("select2-container-disabled"); - }, - - // abstract - opened: function () { - return this.container.hasClass("select2-dropdown-open"); - }, - - // abstract - positionDropdown: function() { - var offset = this.container.offset(), - height = this.container.outerHeight(), - width = this.container.outerWidth(), - dropHeight = this.dropdown.outerHeight(), - viewportBottom = $(window).scrollTop() + document.documentElement.clientHeight, - dropTop = offset.top + height, - dropLeft = offset.left, - enoughRoomBelow = dropTop + dropHeight <= viewportBottom, - enoughRoomAbove = (offset.top - dropHeight) >= this.body().scrollTop(), - aboveNow = this.dropdown.hasClass("select2-drop-above"), - bodyOffset, - above, - css; - - // console.log("below/ droptop:", dropTop, "dropHeight", dropHeight, "sum", (dropTop+dropHeight)+" viewport bottom", viewportBottom, "enough?", enoughRoomBelow); - // console.log("above/ offset.top", offset.top, "dropHeight", dropHeight, "top", (offset.top-dropHeight), "scrollTop", this.body().scrollTop(), "enough?", enoughRoomAbove); - - // fix positioning when body has an offset and is not position: static - - if (this.body().css('position') !== 'static') { - bodyOffset = this.body().offset(); - dropTop -= bodyOffset.top; - dropLeft -= bodyOffset.left; - } - - // always prefer the current above/below alignment, unless there is not enough room - - if (aboveNow) { - above = true; - if (!enoughRoomAbove && enoughRoomBelow) above = false; - } else { - above = false; - if (!enoughRoomBelow && enoughRoomAbove) above = true; - } - - if (above) { - dropTop = offset.top - dropHeight; - this.container.addClass("select2-drop-above"); - this.dropdown.addClass("select2-drop-above"); - } - else { - this.container.removeClass("select2-drop-above"); - this.dropdown.removeClass("select2-drop-above"); - } - - css = $.extend({ - top: dropTop, - left: dropLeft, - width: width - }, evaluate(this.opts.dropdownCss)); - - this.dropdown.css(css); - }, - - // abstract - shouldOpen: function() { - var event; - - if (this.opened()) return false; - - event = $.Event("open"); - this.opts.element.trigger(event); - return !event.isDefaultPrevented(); - }, - - // abstract - clearDropdownAlignmentPreference: function() { - // clear the classes used to figure out the preference of where the dropdown should be opened - this.container.removeClass("select2-drop-above"); - this.dropdown.removeClass("select2-drop-above"); - }, - - /** - * Opens the dropdown - * - * @return {Boolean} whether or not dropdown was opened. This method will return false if, for example, - * the dropdown is already open, or if the 'open' event listener on the element called preventDefault(). - */ - // abstract - open: function () { - - if (!this.shouldOpen()) return false; - - window.setTimeout(this.bind(this.opening), 1); - - return true; - }, - - /** - * Performs the opening of the dropdown - */ - // abstract - opening: function() { - var cid = this.containerId, selector = this.containerSelector, - scroll = "scroll." + cid, resize = "resize." + cid; - - this.container.parents().each(function() { - $(this).bind(scroll, function() { - var s2 = $(selector); - if (s2.length == 0) { - $(this).unbind(scroll); - } - s2.select2("close"); - }); - }); - - $(window).bind(resize, function() { - var s2 = $(selector); - if (s2.length == 0) { - $(window).unbind(resize); - } - s2.select2("close"); - }); - - this.clearDropdownAlignmentPreference(); - - if (this.search.val() === " ") { this.search.val(""); } - - this.container.addClass("select2-dropdown-open").addClass("select2-container-active"); - - this.updateResults(true); - - if(this.dropdown[0] !== this.body().children().last()[0]) { - this.dropdown.detach().appendTo(this.body()); - } - - this.dropdown.show(); - - this.positionDropdown(); - this.dropdown.addClass("select2-drop-active"); - - this.ensureHighlightVisible(); - - this.focusSearch(); - }, - - // abstract - close: function () { - if (!this.opened()) return; - - var self = this; - - this.container.parents().each(function() { - $(this).unbind("scroll." + self.containerId); - }); - $(window).unbind("resize." + this.containerId); - - this.clearDropdownAlignmentPreference(); - - this.dropdown.hide(); - this.container.removeClass("select2-dropdown-open").removeClass("select2-container-active"); - this.results.empty(); - this.clearSearch(); - - this.opts.element.trigger($.Event("close")); - }, - - // abstract - clearSearch: function () { - - }, - - // abstract - ensureHighlightVisible: function () { - var results = this.results, children, index, child, hb, rb, y, more; - - index = this.highlight(); - - if (index < 0) return; - - if (index == 0) { - - // if the first element is highlighted scroll all the way to the top, - // that way any unselectable headers above it will also be scrolled - // into view - - results.scrollTop(0); - return; - } - - children = results.find(".select2-result-selectable"); - - child = $(children[index]); - - hb = child.offset().top + child.outerHeight(); - - // if this is the last child lets also make sure select2-more-results is visible - if (index === children.length - 1) { - more = results.find("li.select2-more-results"); - if (more.length > 0) { - hb = more.offset().top + more.outerHeight(); - } - } - - rb = results.offset().top + results.outerHeight(); - if (hb > rb) { - results.scrollTop(results.scrollTop() + (hb - rb)); - } - y = child.offset().top - results.offset().top; - - // make sure the top of the element is visible - if (y < 0) { - results.scrollTop(results.scrollTop() + y); // y is negative - } - }, - - // abstract - moveHighlight: function (delta) { - var choices = this.results.find(".select2-result-selectable"), - index = this.highlight(); - - while (index > -1 && index < choices.length) { - index += delta; - var choice = $(choices[index]); - if (choice.hasClass("select2-result-selectable") && !choice.hasClass("select2-disabled")) { - this.highlight(index); - break; - } - } - }, - - // abstract - highlight: function (index) { - var choices = this.results.find(".select2-result-selectable").not(".select2-disabled"); - - if (arguments.length === 0) { - return indexOf(choices.filter(".select2-highlighted")[0], choices.get()); - } - - if (index >= choices.length) index = choices.length - 1; - if (index < 0) index = 0; - - choices.removeClass("select2-highlighted"); - - $(choices[index]).addClass("select2-highlighted"); - this.ensureHighlightVisible(); - - }, - - // abstract - countSelectableResults: function() { - return this.results.find(".select2-result-selectable").not(".select2-disabled").length; - }, - - // abstract - highlightUnderEvent: function (event) { - var el = $(event.target).closest(".select2-result-selectable"); - if (el.length > 0 && !el.is(".select2-highlighted")) { - var choices = this.results.find('.select2-result-selectable'); - this.highlight(choices.index(el)); - } else if (el.length == 0) { - // if we are over an unselectable item remove al highlights - this.results.find(".select2-highlighted").removeClass("select2-highlighted"); - } - }, - - // abstract - loadMoreIfNeeded: function () { - var results = this.results, - more = results.find("li.select2-more-results"), - below, // pixels the element is below the scroll fold, below==0 is when the element is starting to be visible - offset = -1, // index of first element without data - page = this.resultsPage + 1, - self=this, - term=this.search.val(), - context=this.context; - - if (more.length === 0) return; - below = more.offset().top - results.offset().top - results.height(); - - if (below <= 0) { - more.addClass("select2-active"); - this.opts.query({ - term: term, - page: page, - context: context, - matcher: this.opts.matcher, - callback: this.bind(function (data) { - - // ignore a response if the select2 has been closed before it was received - if (!self.opened()) return; - - - self.opts.populateResults.call(this, results, data.results, {term: term, page: page, context:context}); - - if (data.more===true) { - more.detach().appendTo(results).text(self.opts.formatLoadMore(page+1)); - window.setTimeout(function() { self.loadMoreIfNeeded(); }, 10); - } else { - more.remove(); - } - self.positionDropdown(); - self.resultsPage = page; - })}); - } - }, - - /** - * Default tokenizer function which does nothing - */ - tokenize: function() { - - }, - - /** - * @param initial whether or not this is the call to this method right after the dropdown has been opened - */ - // abstract - updateResults: function (initial) { - var search = this.search, results = this.results, opts = this.opts, data, self=this, input; - - // if the search is currently hidden we do not alter the results - if (initial !== true && (this.showSearchInput === false || !this.opened())) { - return; - } - - search.addClass("select2-active"); - - function postRender() { - results.scrollTop(0); - search.removeClass("select2-active"); - self.positionDropdown(); - } - - function render(html) { - results.html(self.opts.escapeMarkup(html)); - postRender(); - } - - if (opts.maximumSelectionSize >=1) { - data = this.data(); - if ($.isArray(data) && data.length >= opts.maximumSelectionSize && checkFormatter(opts.formatSelectionTooBig, "formatSelectionTooBig")) { - render("" + opts.formatSelectionTooBig(opts.maximumSelectionSize) + ""); - return; - } - } - - if (search.val().length < opts.minimumInputLength && checkFormatter(opts.formatInputTooShort, "formatInputTooShort")) { - render("" + opts.formatInputTooShort(search.val(), opts.minimumInputLength) + ""); - return; - } - else { - render("" + opts.formatSearching() + ""); - } - - // give the tokenizer a chance to pre-process the input - input = this.tokenize(); - if (input != undefined && input != null) { - search.val(input); - } - - this.resultsPage = 1; - opts.query({ - term: search.val(), - page: this.resultsPage, - context: null, - matcher: opts.matcher, - callback: this.bind(function (data) { - var def; // default choice - - // ignore a response if the select2 has been closed before it was received - if (!this.opened()) return; - - // save context, if any - this.context = (data.context===undefined) ? null : data.context; - - // create a default choice and prepend it to the list - if (this.opts.createSearchChoice && search.val() !== "") { - def = this.opts.createSearchChoice.call(null, search.val(), data.results); - if (def !== undefined && def !== null && self.id(def) !== undefined && self.id(def) !== null) { - if ($(data.results).filter( - function () { - return equal(self.id(this), self.id(def)); - }).length === 0) { - data.results.unshift(def); - } - } - } - - if (data.results.length === 0 && checkFormatter(opts.formatNoMatches, "formatNoMatches")) { - render("" + opts.formatNoMatches(search.val()) + ""); - return; - } - - results.empty(); - self.opts.populateResults.call(this, results, data.results, {term: search.val(), page: this.resultsPage, context:null}); - - if (data.more === true && checkFormatter(opts.formatLoadMore, "formatLoadMore")) { - results.append("" + self.opts.escapeMarkup(opts.formatLoadMore(this.resultsPage)) + ""); - window.setTimeout(function() { self.loadMoreIfNeeded(); }, 10); - } - - this.postprocessResults(data, initial); - - postRender(); - })}); - }, - - // abstract - cancel: function () { - this.close(); - }, - - // abstract - blur: function () { - this.close(); - this.container.removeClass("select2-container-active"); - this.dropdown.removeClass("select2-drop-active"); - // synonymous to .is(':focus'), which is available in jquery >= 1.6 - if (this.search[0] === document.activeElement) { this.search.blur(); } - this.clearSearch(); - this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); - }, - - // abstract - focusSearch: function () { - // need to do it here as well as in timeout so it works in IE - this.search.show(); - this.search.focus(); - - /* we do this in a timeout so that current event processing can complete before this code is executed. - this makes sure the search field is focussed even if the current event would blur it */ - window.setTimeout(this.bind(function () { - // reset the value so IE places the cursor at the end of the input box - this.search.show(); - this.search.focus(); - this.search.val(this.search.val()); - }), 10); - }, - - // abstract - selectHighlighted: function () { - var index=this.highlight(), - highlighted=this.results.find(".select2-highlighted").not(".select2-disabled"), - data = highlighted.closest('.select2-result-selectable').data("select2-data"); - if (data) { - highlighted.addClass("select2-disabled"); - this.highlight(index); - this.onSelect(data); - } - }, - - // abstract - getPlaceholder: function () { - return this.opts.element.attr("placeholder") || - this.opts.element.attr("data-placeholder") || // jquery 1.4 compat - this.opts.element.data("placeholder") || - this.opts.placeholder; - }, - - /** - * Get the desired width for the container element. This is - * derived first from option `width` passed to select2, then - * the inline 'style' on the original element, and finally - * falls back to the jQuery calculated element width. - */ - // abstract - initContainerWidth: function () { - function resolveContainerWidth() { - var style, attrs, matches, i, l; - - if (this.opts.width === "off") { - return null; - } else if (this.opts.width === "element"){ - return this.opts.element.outerWidth() === 0 ? 'auto' : this.opts.element.outerWidth() + 'px'; - } else if (this.opts.width === "copy" || this.opts.width === "resolve") { - // check if there is inline style on the element that contains width - style = this.opts.element.attr('style'); - if (style !== undefined) { - attrs = style.split(';'); - for (i = 0, l = attrs.length; i < l; i = i + 1) { - matches = attrs[i].replace(/\s/g, '') - .match(/width:(([-+]?([0-9]*\.)?[0-9]+)(px|em|ex|%|in|cm|mm|pt|pc))/); - if (matches !== null && matches.length >= 1) - return matches[1]; - } - } - - if (this.opts.width === "resolve") { - // next check if css('width') can resolve a width that is percent based, this is sometimes possible - // when attached to input type=hidden or elements hidden via css - style = this.opts.element.css('width'); - if (style.indexOf("%") > 0) return style; - - // finally, fallback on the calculated width of the element - return (this.opts.element.outerWidth() === 0 ? 'auto' : this.opts.element.outerWidth() + 'px'); - } - - return null; - } else if ($.isFunction(this.opts.width)) { - return this.opts.width(); - } else { - return this.opts.width; - } - }; - - var width = resolveContainerWidth.call(this); - if (width !== null) { - this.container.attr("style", "width: "+width); - } - } - }); - - SingleSelect2 = clazz(AbstractSelect2, { - - // single - - createContainer: function () { - var container = $("", { - "class": "select2-container" - }).html([ - " ", - " ", - " " , - "", - " " , - " " , - " " , - " " , - " " , - " " , - ""].join("")); - return container; - }, - - // single - opening: function () { - this.search.show(); - this.parent.opening.apply(this, arguments); - this.dropdown.removeClass("select2-offscreen"); - }, - - // single - close: function () { - if (!this.opened()) return; - this.parent.close.apply(this, arguments); - this.dropdown.removeAttr("style").addClass("select2-offscreen").insertAfter(this.selection).show(); - }, - - // single - focus: function () { - this.close(); - this.selection.focus(); - }, - - // single - isFocused: function () { - return this.selection[0] === document.activeElement; - }, - - // single - cancel: function () { - this.parent.cancel.apply(this, arguments); - this.selection.focus(); - }, - - // single - initContainer: function () { - - var selection, - container = this.container, - dropdown = this.dropdown, - clickingInside = false; - - this.selection = selection = container.find(".select2-choice"); - - this.search.bind("keydown", this.bind(function (e) { - if (!this.enabled) return; - - if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN) { - // prevent the page from scrolling - killEvent(e); - return; - } - - if (this.opened()) { - switch (e.which) { - case KEY.UP: - case KEY.DOWN: - this.moveHighlight((e.which === KEY.UP) ? -1 : 1); - killEvent(e); - return; - case KEY.TAB: - case KEY.ENTER: - this.selectHighlighted(); - killEvent(e); - return; - case KEY.ESC: - this.cancel(e); - killEvent(e); - return; - } - } else { - - if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) || e.which === KEY.ESC) { - return; - } - - if (this.opts.openOnEnter === false && e.which === KEY.ENTER) { - return; - } - - this.open(); - - if (e.which === KEY.ENTER) { - // do not propagate the event otherwise we open, and propagate enter which closes - return; - } - } - })); - - this.search.bind("focus", this.bind(function() { - this.selection.attr("tabIndex", "-1"); - })); - this.search.bind("blur", this.bind(function() { - if (!this.opened()) this.container.removeClass("select2-container-active"); - window.setTimeout(this.bind(function() { this.selection.attr("tabIndex", this.opts.element.attr("tabIndex")); }), 10); - })); - - selection.bind("mousedown", this.bind(function (e) { - clickingInside = true; - - if (this.opened()) { - this.close(); - this.selection.focus(); - } else if (this.enabled) { - this.open(); - } - - clickingInside = false; - })); - - dropdown.bind("mousedown", this.bind(function() { this.search.focus(); })); - - selection.bind("focus", this.bind(function() { - this.container.addClass("select2-container-active"); - // hide the search so the tab key does not focus on it - this.search.attr("tabIndex", "-1"); - })); - - selection.bind("blur", this.bind(function() { - if (!this.opened()) { - this.container.removeClass("select2-container-active"); - } - window.setTimeout(this.bind(function() { this.search.attr("tabIndex", this.opts.element.attr("tabIndex")); }), 10); - })); - - selection.bind("keydown", this.bind(function(e) { - if (!this.enabled) return; - - if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN) { - // prevent the page from scrolling - killEvent(e); - return; - } - - if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) - || e.which === KEY.ESC) { - return; - } - - if (this.opts.openOnEnter === false && e.which === KEY.ENTER) { - return; - } - - if (e.which == KEY.DELETE) { - if (this.opts.allowClear) { - this.clear(); - } - return; - } - - this.open(); - - if (e.which === KEY.ENTER) { - // do not propagate the event otherwise we open, and propagate enter which closes - killEvent(e); - return; - } - - // do not set the search input value for non-alpha-numeric keys - // otherwise pressing down results in a '(' being set in the search field - if (e.which < 48 ) { // '0' == 48 - killEvent(e); - return; - } - - var keyWritten = String.fromCharCode(e.which).toLowerCase(); - - if (e.shiftKey) { - keyWritten = keyWritten.toUpperCase(); - } - - // focus the field before calling val so the cursor ends up after the value instead of before - this.search.focus(); - this.search.val(keyWritten); - - // prevent event propagation so it doesnt replay on the now focussed search field and result in double key entry - killEvent(e); - })); - - selection.delegate("abbr", "mousedown", this.bind(function (e) { - if (!this.enabled) return; - this.clear(); - killEvent(e); - this.close(); - this.triggerChange(); - this.selection.focus(); - })); - - this.setPlaceholder(); - - this.search.bind("focus", this.bind(function() { - this.container.addClass("select2-container-active"); - })); - }, - - // single - clear: function() { - this.opts.element.val(""); - this.selection.find("span").empty(); - this.selection.removeData("select2-data"); - this.setPlaceholder(); - }, - - /** - * Sets selection based on source element's value - */ - // single - initSelection: function () { - var selected; - if (this.opts.element.val() === "") { - this.close(); - this.setPlaceholder(); - } else { - var self = this; - this.opts.initSelection.call(null, this.opts.element, function(selected){ - if (selected !== undefined && selected !== null) { - self.updateSelection(selected); - self.close(); - self.setPlaceholder(); - } - }); - } - }, - - // single - prepareOpts: function () { - var opts = this.parent.prepareOpts.apply(this, arguments); - - if (opts.element.get(0).tagName.toLowerCase() === "select") { - // install the selection initializer - opts.initSelection = function (element, callback) { - var selected = element.find(":selected"); - // a single select box always has a value, no need to null check 'selected' - if ($.isFunction(callback)) - callback({id: selected.attr("value"), text: selected.text()}); - }; - } - - return opts; - }, - - // single - setPlaceholder: function () { - var placeholder = this.getPlaceholder(); - - if (this.opts.element.val() === "" && placeholder !== undefined) { - - // check for a first blank option if attached to a select - if (this.select && this.select.find("option:first").text() !== "") return; - - this.selection.find("span").html(this.opts.escapeMarkup(placeholder)); - - this.selection.addClass("select2-default"); - - this.selection.find("abbr").hide(); - } - }, - - // single - postprocessResults: function (data, initial) { - var selected = 0, self = this, showSearchInput = true; - - // find the selected element in the result list - - this.results.find(".select2-result-selectable").each2(function (i, elm) { - if (equal(self.id(elm.data("select2-data")), self.opts.element.val())) { - selected = i; - return false; - } - }); - - // and highlight it - - this.highlight(selected); - - // hide the search box if this is the first we got the results and there are a few of them - - if (initial === true) { - showSearchInput = this.showSearchInput = countResults(data.results) >= this.opts.minimumResultsForSearch; - this.dropdown.find(".select2-search")[showSearchInput ? "removeClass" : "addClass"]("select2-search-hidden"); - - //add "select2-with-searchbox" to the container if search box is shown - $(this.dropdown, this.container)[showSearchInput ? "addClass" : "removeClass"]("select2-with-searchbox"); - } - - }, - - // single - onSelect: function (data) { - var old = this.opts.element.val(); - - this.opts.element.val(this.id(data)); - this.updateSelection(data); - this.close(); - this.selection.focus(); - - if (!equal(old, this.id(data))) { this.triggerChange(); } - }, - - // single - updateSelection: function (data) { - - var container=this.selection.find("span"), formatted; - - this.selection.data("select2-data", data); - - container.empty(); - formatted=this.opts.formatSelection(data, container); - if (formatted !== undefined) { - container.append(this.opts.escapeMarkup(formatted)); - } - - this.selection.removeClass("select2-default"); - - if (this.opts.allowClear && this.getPlaceholder() !== undefined) { - this.selection.find("abbr").show(); - } - }, - - // single - val: function () { - var val, data = null, self = this; - - if (arguments.length === 0) { - return this.opts.element.val(); - } - - val = arguments[0]; - - if (this.select) { - this.select - .val(val) - .find(":selected").each2(function (i, elm) { - data = {id: elm.attr("value"), text: elm.text()}; - return false; - }); - this.updateSelection(data); - this.setPlaceholder(); - } else { - if (this.opts.initSelection === undefined) { - throw new Error("cannot call val() if initSelection() is not defined"); - } - // val is an id. !val is true for [undefined,null,''] - if (!val) { - this.clear(); - return; - } - this.opts.element.val(val); - this.opts.initSelection(this.opts.element, function(data){ - self.opts.element.val(!data ? "" : self.id(data)); - self.updateSelection(data); - self.setPlaceholder(); - }); - } - }, - - // single - clearSearch: function () { - this.search.val(""); - }, - - // single - data: function(value) { - var data; - - if (arguments.length === 0) { - data = this.selection.data("select2-data"); - if (data == undefined) data = null; - return data; - } else { - if (!value || value === "") { - this.clear(); - } else { - this.opts.element.val(!value ? "" : this.id(value)); - this.updateSelection(value); - } - } - } - }); - - MultiSelect2 = clazz(AbstractSelect2, { - - // multi - createContainer: function () { - var container = $("", { - "class": "select2-container select2-container-multi" - }).html([ - " ", - //"California" , - " " , - " " , - " " , - "" , - "" , - " " , - " " , - ""].join("")); - return container; - }, - - // multi - prepareOpts: function () { - var opts = this.parent.prepareOpts.apply(this, arguments); - - // TODO validate placeholder is a string if specified - - if (opts.element.get(0).tagName.toLowerCase() === "select") { - // install sthe selection initializer - opts.initSelection = function (element,callback) { - - var data = []; - element.find(":selected").each2(function (i, elm) { - data.push({id: elm.attr("value"), text: elm.text()}); - }); - - if ($.isFunction(callback)) - callback(data); - }; - } - - return opts; - }, - - // multi - initContainer: function () { - - var selector = ".select2-choices", selection; - - this.searchContainer = this.container.find(".select2-search-field"); - this.selection = selection = this.container.find(selector); - - this.search.bind("keydown", this.bind(function (e) { - if (!this.enabled) return; - - if (e.which === KEY.BACKSPACE && this.search.val() === "") { - this.close(); - - var choices, - selected = selection.find(".select2-search-choice-focus"); - if (selected.length > 0) { - this.unselect(selected.first()); - this.search.width(10); - killEvent(e); - return; - } - - choices = selection.find(".select2-search-choice"); - if (choices.length > 0) { - choices.last().addClass("select2-search-choice-focus"); - } - } else { - selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); - } - - if (this.opened()) { - switch (e.which) { - case KEY.UP: - case KEY.DOWN: - this.moveHighlight((e.which === KEY.UP) ? -1 : 1); - killEvent(e); - return; - case KEY.ENTER: - case KEY.TAB: - this.selectHighlighted(); - killEvent(e); - return; - case KEY.ESC: - this.cancel(e); - killEvent(e); - return; - } - } - - if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) - || e.which === KEY.BACKSPACE || e.which === KEY.ESC) { - return; - } - - if (this.opts.openOnEnter === false && e.which === KEY.ENTER) { - return; - } - - this.open(); - - if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN) { - // prevent the page from scrolling - killEvent(e); - } - })); - - this.search.bind("keyup", this.bind(this.resizeSearch)); - - this.search.bind("blur", this.bind(function(e) { - this.container.removeClass("select2-container-active"); - this.search.removeClass("select2-focused"); - this.clearSearch(); - e.stopImmediatePropagation(); - })); - - this.container.delegate(selector, "mousedown", this.bind(function (e) { - if (!this.enabled) return; - if ($(e.target).closest(".select2-search-choice").length > 0) { - // clicked inside a select2 search choice, do not open - return; - } - this.clearPlaceholder(); - this.open(); - this.focusSearch(); - e.preventDefault(); - })); - - this.container.delegate(selector, "focus", this.bind(function () { - if (!this.enabled) return; - this.container.addClass("select2-container-active"); - this.dropdown.addClass("select2-drop-active"); - this.clearPlaceholder(); - })); - - // set the placeholder if necessary - this.clearSearch(); - }, - - // multi - enable: function() { - if (this.enabled) return; - - this.parent.enable.apply(this, arguments); - - this.search.removeAttr("disabled"); - }, - - // multi - disable: function() { - if (!this.enabled) return; - - this.parent.disable.apply(this, arguments); - - this.search.attr("disabled", true); - }, - - // multi - initSelection: function () { - var data; - if (this.opts.element.val() === "") { - this.updateSelection([]); - this.close(); - // set the placeholder if necessary - this.clearSearch(); - } - if (this.select || this.opts.element.val() !== "") { - var self = this; - this.opts.initSelection.call(null, this.opts.element, function(data){ - if (data !== undefined && data !== null) { - self.updateSelection(data); - self.close(); - // set the placeholder if necessary - self.clearSearch(); - } - }); - } - }, - - // multi - clearSearch: function () { - var placeholder = this.getPlaceholder(); - - if (placeholder !== undefined && this.getVal().length === 0 && this.search.hasClass("select2-focused") === false) { - this.search.val(placeholder).addClass("select2-default"); - // stretch the search box to full width of the container so as much of the placeholder is visible as possible - this.resizeSearch(); - } else { - // we set this to " " instead of "" and later clear it on focus() because there is a firefox bug - // that does not properly render the caret when the field starts out blank - this.search.val(" ").width(10); - } - }, - - // multi - clearPlaceholder: function () { - if (this.search.hasClass("select2-default")) { - this.search.val("").removeClass("select2-default"); - } else { - // work around for the space character we set to avoid firefox caret bug - if (this.search.val() === " ") this.search.val(""); - } - }, - - // multi - opening: function () { - this.parent.opening.apply(this, arguments); - - this.clearPlaceholder(); - this.resizeSearch(); - this.focusSearch(); - }, - - // multi - close: function () { - if (!this.opened()) return; - this.parent.close.apply(this, arguments); - }, - - // multi - focus: function () { - this.close(); - this.search.focus(); - }, - - // multi - isFocused: function () { - return this.search.hasClass("select2-focused"); - }, - - // multi - updateSelection: function (data) { - var ids = [], filtered = [], self = this; - - // filter out duplicates - $(data).each(function () { - if (indexOf(self.id(this), ids) < 0) { - ids.push(self.id(this)); - filtered.push(this); - } - }); - data = filtered; - - this.selection.find(".select2-search-choice").remove(); - $(data).each(function () { - self.addSelectedChoice(this); - }); - self.postprocessResults(); - }, - - tokenize: function() { - var input = this.search.val(); - input = this.opts.tokenizer(input, this.data(), this.bind(this.onSelect), this.opts); - if (input != null && input != undefined) { - this.search.val(input); - if (input.length > 0) { - this.open(); - } - } - - }, - - // multi - onSelect: function (data) { - this.addSelectedChoice(data); - if (this.select) { this.postprocessResults(); } - - if (this.opts.closeOnSelect) { - this.close(); - this.search.width(10); - } else { - if (this.countSelectableResults()>0) { - this.search.width(10); - this.resizeSearch(); - this.positionDropdown(); - } else { - // if nothing left to select close - this.close(); - } - } - - // since its not possible to select an element that has already been - // added we do not need to check if this is a new element before firing change - this.triggerChange({ added: data }); - - this.focusSearch(); - }, - - // multi - cancel: function () { - this.close(); - this.focusSearch(); - }, - - // multi - addSelectedChoice: function (data) { - var choice=$( - "" + - " " + - " " + - ""), - id = this.id(data), - val = this.getVal(), - formatted; - - formatted=this.opts.formatSelection(data, choice); - choice.find("div").replaceWith(""+this.opts.escapeMarkup(formatted)+""); - choice.find(".select2-search-choice-close") - .bind("mousedown", killEvent) - .bind("click dblclick", this.bind(function (e) { - if (!this.enabled) return; - - $(e.target).closest(".select2-search-choice").fadeOut('fast', this.bind(function(){ - this.unselect($(e.target)); - this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); - this.close(); - this.focusSearch(); - })).dequeue(); - killEvent(e); - })).bind("focus", this.bind(function () { - if (!this.enabled) return; - this.container.addClass("select2-container-active"); - this.dropdown.addClass("select2-drop-active"); - })); - - choice.data("select2-data", data); - choice.insertBefore(this.searchContainer); - - val.push(id); - this.setVal(val); - }, - - // multi - unselect: function (selected) { - var val = this.getVal(), - data, - index; - - selected = selected.closest(".select2-search-choice"); - - if (selected.length === 0) { - throw "Invalid argument: " + selected + ". Must be .select2-search-choice"; - } - - data = selected.data("select2-data"); - - index = indexOf(this.id(data), val); - - if (index >= 0) { - val.splice(index, 1); - this.setVal(val); - if (this.select) this.postprocessResults(); - } - selected.remove(); - this.triggerChange({ removed: data }); - }, - - // multi - postprocessResults: function () { - var val = this.getVal(), - choices = this.results.find(".select2-result-selectable"), - compound = this.results.find(".select2-result-with-children"), - self = this; - - choices.each2(function (i, choice) { - var id = self.id(choice.data("select2-data")); - if (indexOf(id, val) >= 0) { - choice.addClass("select2-disabled").removeClass("select2-result-selectable"); - } else { - choice.removeClass("select2-disabled").addClass("select2-result-selectable"); - } - }); - - compound.each2(function(i, e) { - if (e.find(".select2-result-selectable").length==0) { - e.addClass("select2-disabled"); - } else { - e.removeClass("select2-disabled"); - } - }); - - choices.each2(function (i, choice) { - if (!choice.hasClass("select2-disabled") && choice.hasClass("select2-result-selectable")) { - self.highlight(0); - return false; - } - }); - - }, - - // multi - resizeSearch: function () { - - var minimumWidth, left, maxWidth, containerLeft, searchWidth, - sideBorderPadding = getSideBorderPadding(this.search); - - minimumWidth = measureTextWidth(this.search) + 10; - - left = this.search.offset().left; - - maxWidth = this.selection.width(); - containerLeft = this.selection.offset().left; - - searchWidth = maxWidth - (left - containerLeft) - sideBorderPadding; - if (searchWidth < minimumWidth) { - searchWidth = maxWidth - sideBorderPadding; - } - - if (searchWidth < 40) { - searchWidth = maxWidth - sideBorderPadding; - } - this.search.width(searchWidth); - }, - - // multi - getVal: function () { - var val; - if (this.select) { - val = this.select.val(); - return val === null ? [] : val; - } else { - val = this.opts.element.val(); - return splitVal(val, this.opts.separator); - } - }, - - // multi - setVal: function (val) { - var unique; - if (this.select) { - this.select.val(val); - } else { - unique = []; - // filter out duplicates - $(val).each(function () { - if (indexOf(this, unique) < 0) unique.push(this); - }); - this.opts.element.val(unique.length === 0 ? "" : unique.join(this.opts.separator)); - } - }, - - // multi - val: function () { - var val, data = [], self=this; - - if (arguments.length === 0) { - return this.getVal(); - } - - val = arguments[0]; - - if (!val) { - this.opts.element.val(""); - this.updateSelection([]); - this.clearSearch(); - return; - } - - // val is a list of ids - this.setVal(val); - - if (this.select) { - this.select.find(":selected").each(function () { - data.push({id: $(this).attr("value"), text: $(this).text()}); - }); - this.updateSelection(data); - } else { - if (this.opts.initSelection === undefined) { - throw new Error("val() cannot be called if initSelection() is not defined") - } - - this.opts.initSelection(this.opts.element, function(data){ - var ids=$(data).map(self.id); - self.setVal(ids); - self.updateSelection(data); - self.clearSearch(); - }); - } - this.clearSearch(); - }, - - // multi - onSortStart: function() { - if (this.select) { - throw new Error("Sorting of elements is not supported when attached to . Attach to instead."); - } - - // collapse search field into 0 width so its container can be collapsed as well - this.search.width(0); - // hide the container - this.searchContainer.hide(); - }, - - // multi - onSortEnd:function() { - - var val=[], self=this; - - // show search and move it to the end of the list - this.searchContainer.show(); - // make sure the search container is the last item in the list - this.searchContainer.appendTo(this.searchContainer.parent()); - // since we collapsed the width in dragStarted, we resize it here - this.resizeSearch(); - - // update selection - - this.selection.find(".select2-search-choice").each(function() { - val.push(self.opts.id($(this).data("select2-data"))); - }); - this.setVal(val); - this.triggerChange(); - }, - - // multi - data: function(values) { - var self=this, ids; - if (arguments.length === 0) { - return this.selection - .find(".select2-search-choice") - .map(function() { return $(this).data("select2-data"); }) - .get(); - } else { - if (!values) { values = []; } - ids = $.map(values, function(e) { return self.opts.id(e)}); - this.setVal(ids); - this.updateSelection(values); - this.clearSearch(); - } - } - }); - - $.fn.select2 = function () { - - var args = Array.prototype.slice.call(arguments, 0), - opts, - select2, - value, multiple, allowedMethods = ["val", "destroy", "opened", "open", "close", "focus", "isFocused", "container", "onSortStart", "onSortEnd", "enable", "disable", "positionDropdown", "data"]; - - this.each(function () { - if (args.length === 0 || typeof(args[0]) === "object") { - opts = args.length === 0 ? {} : $.extend({}, args[0]); - opts.element = $(this); - - if (opts.element.get(0).tagName.toLowerCase() === "select") { - multiple = opts.element.attr("multiple"); - } else { - multiple = opts.multiple || false; - if ("tags" in opts) {opts.multiple = multiple = true;} - } - - select2 = multiple ? new MultiSelect2() : new SingleSelect2(); - select2.init(opts); - } else if (typeof(args[0]) === "string") { - - if (indexOf(args[0], allowedMethods) < 0) { - throw "Unknown method: " + args[0]; - } - - value = undefined; - select2 = $(this).data("select2"); - if (select2 === undefined) return; - if (args[0] === "container") { - value=select2.container; - } else { - value = select2[args[0]].apply(select2, args.slice(1)); - } - if (value !== undefined) {return false;} - } else { - throw "Invalid arguments to select2 plugin: " + args; - } - }); - return (value === undefined) ? this : value; - }; - - // plugin defaults, accessible to users - $.fn.select2.defaults = { - width: "copy", - closeOnSelect: true, - openOnEnter: true, - containerCss: {}, - dropdownCss: {}, - containerCssClass: "", - dropdownCssClass: "", - formatResult: function(result, container, query) { - var markup=[]; - markMatch(result.text, query.term, markup); - return markup.join(""); - }, - formatSelection: function (data, container) { - return data ? data.text : undefined; - }, - formatResultCssClass: function(data) {return undefined;}, - formatNoMatches: function () { return "No matches found"; }, - formatInputTooShort: function (input, min) { return "Please enter " + (min - input.length) + " more characters"; }, - formatSelectionTooBig: function (limit) { return "You can only select " + limit + " item" + (limit == 1 ? "" : "s"); }, - formatLoadMore: function (pageNumber) { return "Loading more results..."; }, - formatSearching: function () { return "Searching..."; }, - minimumResultsForSearch: 0, - minimumInputLength: 0, - maximumSelectionSize: 0, - id: function (e) { return e.id; }, - matcher: function(term, text) { - return text.toUpperCase().indexOf(term.toUpperCase()) >= 0; - }, - separator: ",", - tokenSeparators: [], - tokenizer: defaultTokenizer, - escapeMarkup: function (markup) { - if (markup && typeof(markup) === "string") { - return markup.replace(/&/g, "&"); - } - return markup; - }, - blurOnChange: false - }; - - // exports - window.Select2 = { - query: { - ajax: ajax, - local: local, - tags: tags - }, util: { - debounce: debounce, - markMatch: markMatch - }, "class": { - "abstract": AbstractSelect2, - "single": SingleSelect2, - "multi": MultiSelect2 - } - }; - -}(jQuery)); diff --git a/misc/demo/assets/select2.png b/misc/demo/assets/select2.png deleted file mode 100644 index 1d804ffb99..0000000000 Binary files a/misc/demo/assets/select2.png and /dev/null differ diff --git a/misc/demo/assets/smoothscroll-angular-custom.js b/misc/demo/assets/smoothscroll-angular-custom.js new file mode 100644 index 0000000000..e33586f5c0 --- /dev/null +++ b/misc/demo/assets/smoothscroll-angular-custom.js @@ -0,0 +1,97 @@ +/* + * https://github.com/alicelieutier/smoothScroll/ + * A teeny tiny, standard compliant, smooth scroll script with ease-in-out effect and no jQuery (or any other dependancy, FWIW). + * MIT License + */ +window.smoothScroll = (function(){ +// We do not want this script to be applied in browsers that do not support those +// That means no smoothscroll on IE9 and below. +if(document.querySelectorAll === void 0 || window.pageYOffset === void 0 || history.pushState === void 0) { return; } + +// Get the top position of an element in the document +var getTop = function(element) { + // return value of html.getBoundingClientRect().top ... IE : 0, other browsers : -pageYOffset + if(element.nodeName === 'HTML') return -window.pageYOffset + return element.getBoundingClientRect().top + window.pageYOffset; +} +// ease in out function thanks to: +// http://blog.greweb.fr/2012/02/bezier-curve-based-easing-functions-from-concept-to-implementation/ +var easeInOutCubic = function (t) { return t<.5 ? 4*t*t*t : (t-1)*(2*t-2)*(2*t-2)+1 } + +// calculate the scroll position we should be in +// given the start and end point of the scroll +// the time elapsed from the beginning of the scroll +// and the total duration of the scroll (default 500ms) +var position = function(start, end, elapsed, duration) { + if (elapsed > duration) return end; + return start + (end - start) * easeInOutCubic(elapsed / duration); // <-- you can change the easing funtion there + // return start + (end - start) * (elapsed / duration); // <-- this would give a linear scroll +} + +// we use requestAnimationFrame to be called by the browser before every repaint +// if the first argument is an element then scroll to the top of this element +// if the first argument is numeric then scroll to this location +// if the callback exist, it is called when the scrolling is finished +var smoothScroll = function(el, duration, callback){ + duration = duration || 500; + var start = window.pageYOffset; + + if (typeof el === 'number') { + var end = parseInt(el); + } else { + var end = getTop(el); + } + + var clock = Date.now(); + var requestAnimationFrame = window.requestAnimationFrame || + window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || + function(fn){window.setTimeout(fn, 15);}; + + var step = function(){ + var elapsed = Date.now() - clock; + window.scroll(0, position(start, end, elapsed, duration)); + if (elapsed > duration) { + if (typeof callback === 'function') { + callback(el); + } + } else { + requestAnimationFrame(step); + } + } + step(); +} + +var linkHandler = function(ev) { + ev.preventDefault(); + + if (location.hash !== this.hash) { + //NOTE(@ajoslin): Changed this line to stop $digest errors + //window.history.pushState(null, null, this.hash) + angular.element(document).injector().get('$location').hash(this.hash); + } + // using the history api to solve issue #1 - back doesn't work + // most browser don't update :target when the history api is used: + // THIS IS A BUG FROM THE BROWSERS. + // change the scrolling duration in this call + var targetEl = document.getElementById(this.hash.substring(1)); + if (targetEl) { + smoothScroll(document.getElementById(this.hash.substring(1)), 500, function(el) { + location.replace('#' + el.id) + // this will cause the :target to be activated. + }); + } +} + +// We look for all the internal links in the documents and attach the smoothscroll function +document.addEventListener("DOMContentLoaded", function () { + var internal = document.querySelectorAll('a[href^="#"]'), a; + for(var i=internal.length; a=internal[--i];){ + a.addEventListener("click", linkHandler, false); + } +}); + +// return smoothscroll API +return smoothScroll; + +})(); + diff --git a/misc/demo/assets/uglifyjs.js b/misc/demo/assets/uglifyjs.js new file mode 100644 index 0000000000..2b09c57c26 --- /dev/null +++ b/misc/demo/assets/uglifyjs.js @@ -0,0 +1,4 @@ +!function(n,e){"use strict";function t(n){for(var e=Object.create(null),t=0;t=0;)if(e[t]==n)return!0;return!1}function a(n,e){for(var t=0,r=e.length;r>t;++t)if(n(e[t]))return e[t]}function u(n,e){if(0>=e)return"";if(1==e)return n;var t=u(n,e>>1);return t+=t,1&e&&(t+=n),t}function s(n,e){Error.call(this,n),this.msg=n,this.defs=e}function c(n,e,t){n===!0&&(n={});var r=n||{};if(t)for(var i in r)r.hasOwnProperty(i)&&!e.hasOwnProperty(i)&&s.croak("`"+i+"` is not a supported option",e);for(var i in e)e.hasOwnProperty(i)&&(r[i]=n&&n.hasOwnProperty(i)?n[i]:e[i]);return r}function f(n,e){for(var t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n}function l(){}function p(n,e){n.indexOf(e)<0&&n.push(e)}function d(n,e){return n.replace(/\{(.+?)\}/g,function(n,t){return e[t]})}function h(n,e){for(var t=n.length;--t>=0;)n[t]===e&&n.splice(t,1)}function _(n,e){function t(n,t){for(var r=[],i=0,o=0,a=0;i=0})}function g(n){function e(n){if(1==n.length)return t+="return str === "+JSON.stringify(n[0])+";";t+="switch(str){";for(var e=0;e3){r.sort(function(n,e){return e.length-n.length}),t+="switch(str.length){";for(var i=0;i=0;)if(!e(n[t]))return!1;return!0}function y(){this._values=Object.create(null),this._size=0}function A(n,e,t,r){arguments.length<4&&(r=W),e=e?e.split(/\s+/):[];var i=e;r&&r.PROPS&&(e=e.concat(r.PROPS));for(var o="return function AST_"+n+"(props){ if (props) { ",a=e.length;--a>=0;)o+="this."+e[a]+" = props."+e[a]+";";var u=r&&new r;(u&&u.initialize||t&&t.initialize)&&(o+="this.initialize();"),o+="}}";var s=new Function(o)();if(u&&(s.prototype=u,s.BASE=r),r&&r.SUBCLASSES.push(s),s.prototype.CTOR=s,s.PROPS=e||null,s.SELF_PROPS=i,s.SUBCLASSES=[],n&&(s.prototype.TYPE=s.TYPE=n),t)for(a in t)t.hasOwnProperty(a)&&(/^\$/.test(a)?s[a.substr(1)]=t[a]:s.prototype[a]=t[a]);return s.DEFMETHOD=function(n,e){this.prototype[n]=e},s}function w(n,e){n.body instanceof Y?n.body._walk(e):n.body.forEach(function(n){n._walk(e)})}function E(n){this.visit=n,this.stack=[]}function D(n){return n>=97&&122>=n||n>=65&&90>=n||n>=170&&qt.letter.test(String.fromCharCode(n))}function F(n){return n>=48&&57>=n}function S(n){return F(n)||D(n)}function C(n){return qt.non_spacing_mark.test(n)||qt.space_combining_mark.test(n)}function k(n){return qt.connector_punctuation.test(n)}function x(n){return!St(n)&&/^[a-z_$][a-z0-9_$]*$/i.test(n)}function B(n){return 36==n||95==n||D(n)}function T(n){var e=n.charCodeAt(0);return B(e)||F(e)||8204==e||8205==e||C(n)||k(n)}function $(n){return/^[a-z_$][a-z0-9_$]*$/i.test(n)}function O(n){return xt.test(n)?parseInt(n.substr(2),16):Bt.test(n)?parseInt(n.substr(1),8):Tt.test(n)?parseFloat(n):void 0}function M(n,e,t,r){this.message=n,this.line=e,this.col=t,this.pos=r,this.stack=(new Error).stack}function N(n,e,t,r,i){throw new M(n,t,r,i)}function R(n,e,t){return n.type==e&&(null==t||n.value==t)}function q(n,e,t){function r(){return D.text.charAt(D.pos)}function i(n,e){var t=D.text.charAt(D.pos++);if(n&&!t)throw Ht;return"\n"==t?(D.newline_before=D.newline_before||!e,++D.line,D.col=0):++D.col,t}function o(n){for(;n-->0;)i()}function a(n){return D.text.substr(D.pos,n.length)==n}function u(n,e){var t=D.text.indexOf(n,D.pos);if(e&&-1==t)throw Ht;return t}function s(){D.tokline=D.line,D.tokcol=D.col,D.tokpos=D.pos}function c(n,t,r){D.regex_allowed="operator"==n&&!Pt(t)||"keyword"==n&&Ct(t)||"punc"==n&&Mt(t),C="punc"==n&&"."==t;var i={type:n,value:t,line:D.tokline,col:D.tokcol,pos:D.tokpos,endpos:D.pos,nlb:D.newline_before,file:e};if(!r){i.comments_before=D.comments_before,D.comments_before=[];for(var o=0,a=i.comments_before.length;a>o;o++)i.nlb=i.nlb||i.comments_before[o].nlb}return D.newline_before=!1,new L(i)}function f(){for(;Ot(r());)i()}function l(n){for(var e,t="",o=0;(e=r())&&n(e,o++);)t+=i();return t}function p(n){N(n,e,D.tokline,D.tokcol,D.tokpos)}function d(n){var e=!1,t=!1,r=!1,i="."==n,o=l(function(o,a){var u=o.charCodeAt(0);switch(u){case 120:case 88:return r?!1:r=!0;case 101:case 69:return r?!0:e?!1:e=t=!0;case 45:return t||0==a&&!n;case 43:return t;case t=!1,46:return i||r||e?!1:i=!0}return S(u)});n&&(o=n+o);var a=O(o);return isNaN(a)?void p("Invalid syntax: "+o):c("num",a)}function h(n){var e=i(!0,n);switch(e.charCodeAt(0)){case 110:return"\n";case 114:return"\r";case 116:return" ";case 98:return"\b";case 118:return"";case 102:return"\f";case 48:return"\x00";case 120:return String.fromCharCode(_(2));case 117:return String.fromCharCode(_(4));case 10:return"";default:return e}}function _(n){for(var e=0;n>0;--n){var t=parseInt(i(!0),16);isNaN(t)&&p("Invalid hex-character pattern in string"),e=e<<4|t}return e}function m(n){var e,t=D.regex_allowed,r=u("\n");return-1==r?(e=D.text.substr(D.pos),D.pos=D.text.length):(e=D.text.substring(D.pos,r),D.pos=r),D.comments_before.push(c(n,e,!0)),D.regex_allowed=t,E()}function v(){for(var n,e,t=!1,o="",a=!1;null!=(n=r());)if(t)"u"!=n&&p("Expecting UnicodeEscapeSequence -- uXXXX"),n=h(),T(n)||p("Unicode char: "+n.charCodeAt(0)+" is not valid in identifier"),o+=n,t=!1;else if("\\"==n)a=t=!0,i();else{if(!T(n))break;o+=i()}return Dt(o)&&a&&(e=o.charCodeAt(0).toString(16).toUpperCase(),o="\\u"+"0000".substr(e.length)+e+o.slice(1)),o}function g(n){function e(n){if(!r())return n;var t=n+r();return $t(t)?(i(),e(t)):n}return c("operator",e(n||i()))}function b(){switch(i(),r()){case"/":return i(),m("comment1");case"*":return i(),x()}return D.regex_allowed?$(""):g("/")}function y(){return i(),F(r().charCodeAt(0))?d("."):c("punc",".")}function A(){var n=v();return C?c("name",n):Ft(n)?c("atom",n):Dt(n)?$t(n)?c("operator",n):c("keyword",n):c("name",n)}function w(n,e){return function(t){try{return e(t)}catch(r){if(r!==Ht)throw r;p(n)}}}function E(n){if(null!=n)return $(n);if(f(),s(),t){if(a("")&&D.newline_before)return o(3),m("comment4")}var e=r();if(!e)return c("eof");var u=e.charCodeAt(0);switch(u){case 34:case 39:return k();case 46:return y();case 47:return b()}return F(u)?d():Nt(e)?c("punc",i()):kt(e)?g():92==u||B(u)?A():void p("Unexpected character '"+e+"'")}var D={text:n.replace(/\r\n?|[\n\u2028\u2029]/g,"\n").replace(/\uFEFF/g,""),filename:e,pos:0,tokpos:0,line:1,tokline:0,col:0,tokcol:0,newline_before:!1,regex_allowed:!1,comments_before:[]},C=!1,k=w("Unterminated string constant",function(){for(var n=i(),e="";;){var t=i(!0);if("\\"==t){var r=0,o=null;t=l(function(n){if(n>="0"&&"7">=n){if(!o)return o=n,++r;if("3">=o&&2>=r)return++r;if(o>="4"&&1>=r)return++r}return!1}),t=r>0?String.fromCharCode(parseInt(t,8)):h(!0)}else if(t==n)break;e+=t}return c("string",e)}),x=w("Unterminated multiline comment",function(){var n=D.regex_allowed,e=u("*/",!0),t=D.text.substring(D.pos,e),r=t.split("\n"),i=r.length;D.pos=e+2,D.line+=i-1,i>1?D.col=r[i-1].length:D.col+=r[i-1].length,D.col+=2;var o=D.newline_before=D.newline_before||t.indexOf("\n")>=0;return D.comments_before.push(c("comment2",t,!0)),D.regex_allowed=n,D.newline_before=o,E()}),$=w("Unterminated regular expression",function(n){for(var e,t=!1,r=!1;e=i(!0);)if(t)n+="\\"+e,t=!1;else if("["==e)r=!0,n+=e;else if("]"==e&&r)r=!1,n+=e;else{if("/"==e&&!r)break;"\\"==e?t=!0:n+=e}var o=v();return c("regexp",new RegExp(n,o))});return E.context=function(n){return n&&(D=n),D},E}function H(n,e){function t(n,e){return R(I.token,n,e)}function r(){return I.peeked||(I.peeked=I.input())}function i(){return I.prev=I.token,I.peeked?(I.token=I.peeked,I.peeked=null):I.token=I.input(),I.in_directives=I.in_directives&&("string"==I.token.type||t("punc",";")),I.token}function o(){return I.prev}function u(n,e,t,r){var i=I.input.context();N(n,i.filename,null!=e?e:i.tokline,null!=t?t:i.tokcol,null!=r?r:i.tokpos)}function s(n,e){u(e,n.line,n.col)}function f(n){null==n&&(n=I.token),s(n,"Unexpected token: "+n.type+" ("+n.value+")")}function l(n,e){return t(n,e)?i():void s(I.token,"Unexpected token "+I.token.type+" «"+I.token.value+"», expected "+n+" «"+e+"»")}function p(n){return l("punc",n)}function d(){return!e.strict&&(I.token.nlb||t("eof")||t("punc","}"))}function h(){t("punc",";")?i():d()||f()}function _(){p("(");var n=De(!0);return p(")"),n}function m(n){return function(){var e=I.token,t=n(),r=o();return t.start=e,t.end=r,t}}function v(){(t("operator","/")||t("operator","/="))&&(I.peeked=null,I.token=I.input(I.token.value.substr(1)))}function g(){var n=M(ut);a(function(e){return e.name==n.name},I.labels)&&u("Label "+n.name+" defined twice"),p(":"),I.labels.push(n);var e=U();return I.labels.pop(),e instanceof te||n.references.forEach(function(e){e instanceof Ae&&(e=e.label.start,u("Continue label `"+n.name+"` refers to non-IterationStatement.",e.line,e.col,e.pos))}),new ee({body:e,label:n})}function b(n){return new K({body:(n=De(!0),h(),n)})}function y(n){var e,t=null;d()||(t=M(ct,!0)),null!=t?(e=a(function(n){return n.name==t.name},I.labels),e||u("Undefined label "+t.name),t.thedef=e):0==I.in_loop&&u(n.TYPE+" not inside a loop or switch"),h();var r=new n({label:t});return e&&e.references.push(r),r}function A(){p("(");var n=null;return!t("punc",";")&&(n=t("keyword","var")?(i(),L(!0)):De(!0,!0),t("operator","in"))?(n instanceof Te&&n.definitions.length>1&&u("Only one variable declaration allowed in for..in loop"),i(),E(n)):w(n)}function w(n){p(";");var e=t("punc",";")?null:De(!0);p(";");var r=t("punc",")")?null:De(!0);return p(")"),new ae({init:n,condition:e,step:r,body:j(U)})}function E(n){var e=n instanceof Te?n.definitions[0].name:null,t=De(!0);return p(")"),new ue({init:n,name:e,object:t,body:j(U)})}function D(){var n=_(),e=U(),r=null;return t("keyword","else")&&(i(),r=U()),new we({condition:n,body:e,alternative:r})}function F(){p("{");for(var n=[];!t("punc","}");)t("eof")&&f(),n.push(U());return i(),n}function S(){p("{");for(var n,e=[],r=null,a=null;!t("punc","}");)t("eof")&&f(),t("keyword","case")?(a&&(a.end=o()),r=[],a=new Se({start:(n=I.token,i(),n),expression:De(!0),body:r}),e.push(a),p(":")):t("keyword","default")?(a&&(a.end=o()),r=[],a=new Fe({start:(n=I.token,i(),p(":"),n),body:r}),e.push(a)):(r||f(),r.push(U()));return a&&(a.end=o()),i(),e}function C(){var n=F(),e=null,r=null;if(t("keyword","catch")){var a=I.token;i(),p("(");var s=M(at);p(")"),e=new ke({start:a,argname:s,body:F(),end:o()})}if(t("keyword","finally")){var a=I.token;i(),r=new xe({start:a,body:F(),end:o()})}return e||r||u("Missing catch/finally blocks"),new Ce({body:n,bcatch:e,bfinally:r})}function k(n,e){for(var r=[];r.push(new Oe({start:I.token,name:M(e?tt:et),value:t("operator","=")?(i(),De(!1,n)):null,end:o()})),t("punc",",");)i();return r}function x(){var n,e=I.token;switch(e.type){case"name":case"keyword":n=O(st);break;case"num":n=new dt({start:e,end:e,value:e.value});break;case"string":n=new pt({start:e,end:e,value:e.value});break;case"regexp":n=new ht({start:e,end:e,value:e.value});break;case"atom":switch(e.value){case"false":n=new wt({start:e,end:e});break;case"true":n=new Et({start:e,end:e});break;case"null":n=new mt({start:e,end:e})}}return i(),n}function B(n,e,r){for(var o=!0,a=[];!t("punc",n)&&(o?o=!1:p(","),!e||!t("punc",n));)a.push(t("punc",",")&&r?new bt({start:I.token,end:I.token}):De(!1));return i(),a}function T(){var n=I.token;switch(i(),n.type){case"num":case"string":case"name":case"operator":case"keyword":case"atom":return n.value;default:f()}}function $(){var n=I.token;switch(i(),n.type){case"name":case"operator":case"keyword":case"atom":return n.value;default:f()}}function O(n){var e=I.token.value;return new("this"==e?ft:n)({name:String(e),start:I.token,end:I.token})}function M(n,e){if(!t("name"))return e||u("Name expected"),null;var r=O(n);return i(),r}function H(n,e,t){return"++"!=e&&"--"!=e||P(t)||u("Invalid use of "+e+" operator"),new n({operator:e,expression:t})}function z(n){return _e(le(!0),0,n)}function P(n){return e.strict?n instanceof ft?!1:n instanceof qe||n instanceof Ze:!0}function j(n){++I.in_loop;var e=n();return--I.in_loop,e}e=c(e,{strict:!1,filename:null,toplevel:null,expression:!1,html5_comments:!0});var I={input:"string"==typeof n?q(n,e.filename,e.html5_comments):n,token:null,prev:null,peeked:null,in_function:0,in_directives:!0,in_loop:0,labels:[]};I.token=i();var U=m(function(){var n;switch(v(),I.token.type){case"string":var e=I.in_directives,a=b();return e&&a.body instanceof pt&&!t("punc",",")?new G({value:a.body.value}):a;case"num":case"regexp":case"operator":case"atom":return b();case"name":return R(r(),"punc",":")?g():b();case"punc":switch(I.token.value){case"{":return new Z({start:I.token,body:F(),end:o()});case"[":case"(":return b();case";":return i(),new Q;default:f()}case"keyword":switch(n=I.token.value,i(),n){case"break":return y(ye);case"continue":return y(Ae);case"debugger":return h(),new X;case"do":return new ie({body:j(U),condition:(l("keyword","while"),n=_(),h(),n)});case"while":return new oe({condition:_(),body:j(U)});case"for":return A();case"function":return V(he);case"if":return D();case"return":return 0==I.in_function&&u("'return' outside of function"),new ve({value:t("punc",";")?(i(),null):d()?null:(n=De(!0),h(),n)});case"switch":return new Ee({expression:_(),body:j(S)});case"throw":return I.token.nlb&&u("Illegal newline after 'throw'"),new ge({value:(n=De(!0),h(),n)});case"try":return C();case"var":return n=L(),h(),n;case"const":return n=W(),h(),n;case"with":return new se({expression:_(),body:U()});default:f()}}}),V=function(n){var e=n===he,r=t("name")?M(e?it:ot):null;return e&&!r&&f(),p("("),new n({name:r,argnames:function(n,e){for(;!t("punc",")");)n?n=!1:p(","),e.push(M(rt));return i(),e}(!0,[]),body:function(n,e){++I.in_function,I.in_directives=!0,I.in_loop=0,I.labels=[];var t=F();return--I.in_function,I.in_loop=n,I.labels=e,t}(I.in_loop,I.labels)})},L=function(n){return new Te({start:o(),definitions:k(n,!1),end:o()})},W=function(){return new $e({start:o(),definitions:k(!1,!0),end:o()})},Y=function(){var n=I.token;l("operator","new");var e,r=J(!1);return t("punc","(")?(i(),e=B(")")):e=[],ce(new Ne({start:n,expression:r,args:e,end:o()}),!0)},J=function(n){if(t("operator","new"))return Y();var e=I.token;if(t("punc")){switch(e.value){case"(":i();var r=De(!0);return r.start=e,r.end=I.token,p(")"),ce(r,n);case"[":return ce(ne(),n);case"{":return ce(re(),n)}f()}if(t("keyword","function")){i();var a=V(de);return a.start=e,a.end=o(),ce(a,n)}return Vt[I.token.type]?ce(x(),n):void f()},ne=m(function(){return p("["),new We({elements:B("]",!e.strict,!0)})}),re=m(function(){p("{");for(var n=!0,r=[];!t("punc","}")&&(n?n=!1:p(","),e.strict||!t("punc","}"));){var a=I.token,u=a.type,s=T();if("name"==u&&!t("punc",":")){if("get"==s){r.push(new Je({start:a,key:x(),value:V(pe),end:o()}));continue}if("set"==s){r.push(new Ke({start:a,key:x(),value:V(pe),end:o()}));continue}}p(":"),r.push(new Ge({start:a,key:s,value:De(!1),end:o()}))}return i(),new Ye({properties:r})}),ce=function(n,e){var r=n.start;if(t("punc","."))return i(),ce(new He({start:r,expression:n,property:$(),end:o()}),e);if(t("punc","[")){i();var a=De(!0);return p("]"),ce(new ze({start:r,expression:n,property:a,end:o()}),e)}return e&&t("punc","(")?(i(),ce(new Me({start:r,expression:n,args:B(")"),end:o()}),!0)):n},le=function(n){var e=I.token;if(t("operator")&&zt(e.value)){i(),v();var r=H(je,e.value,le(n));return r.start=e,r.end=o(),r}for(var a=J(n);t("operator")&&Pt(I.token.value)&&!I.token.nlb;)a=H(Ie,I.token.value,a),a.start=e,a.end=I.token,i();return a},_e=function(n,e,r){var o=t("operator")?I.token.value:null;"in"==o&&r&&(o=null);var a=null!=o?It[o]:null;if(null!=a&&a>e){i();var u=_e(le(!0),a,r);return _e(new Ue({start:n.start,left:n,operator:o,right:u,end:u.end}),e,r)}return n},me=function(n){var e=I.token,r=z(n);if(t("operator","?")){i();var a=De(!1);return p(":"),new Ve({start:e,condition:r,consequent:a,alternative:De(!1,n),end:o()})}return r},be=function(n){var e=I.token,r=me(n),a=I.token.value;if(t("operator")&&jt(a)){if(P(r))return i(),new Le({start:e,left:r,operator:a,right:be(n),end:o()});u("Invalid assignment")}return r},De=function(n,e){var o=I.token,a=be(e);return n&&t("punc",",")?(i(),new Re({start:o,car:a,cdr:De(!0,e),end:r()})):a};return e.expression?De(!0):function(){for(var n=I.token,r=[];!t("eof");)r.push(U());var i=o(),a=e.toplevel;return a?(a.body=a.body.concat(r),a.end=i):a=new fe({start:n,body:r,end:i}),a}()}function z(n,e){E.call(this),this.before=n,this.after=e}function P(n,e,t){this.name=t.name,this.orig=[t],this.scope=n,this.references=[],this.global=!1,this.mangled_name=null,this.undeclared=!1,this.constant=!1,this.index=e}function j(n){function e(n,e){return n.replace(/[\u0080-\uffff]/g,function(n){var t=n.charCodeAt(0).toString(16);if(t.length<=2&&!e){for(;t.length<2;)t="0"+t;return"\\x"+t}for(;t.length<4;)t="0"+t;return"\\u"+t})}function t(t){var r=0,i=0;return t=t.replace(/[\\\b\f\n\r\t\x22\x27\u2028\u2029\0]/g,function(n){switch(n){case"\\":return"\\\\";case"\b":return"\\b";case"\f":return"\\f";case"\n":return"\\n";case"\r":return"\\r";case"\u2028":return"\\u2028";case"\u2029":return"\\u2029";case'"':return++r,'"';case"'":return++i,"'";case"\x00":return"\\x00"}return n}),n.ascii_only&&(t=e(t)),r>i?"'"+t.replace(/\x27/g,"\\'")+"'":'"'+t.replace(/\x22/g,'\\"')+'"'}function r(e){var r=t(e);return n.inline_script&&(r=r.replace(/<\x2fscript([>\/\t\n\f\r ])/gi,"<\\/script$1")),r}function i(t){return t=t.toString(),n.ascii_only&&(t=e(t,!0)),t}function o(e){return u(" ",n.indent_start+A-e*n.indent_level)}function a(){return k.charAt(k.length-1)}function s(){n.max_line_len&&w>n.max_line_len&&f("\n")}function f(e){e=String(e);var t=e.charAt(0);if(C&&(t&&!(";}".indexOf(t)<0)||/[;]$/.test(k)||(n.semicolons||x(t)?(F+=";",w++,D++):(F+="\n",D++,E++,w=0),n.beautify||(S=!1)),C=!1,s()),!n.beautify&&n.preserve_line&&q[q.length-1])for(var r=q[q.length-1].start.line;r>E;)F+="\n",D++,E++,w=0,S=!1;if(S){var i=a();(T(i)&&(T(t)||"\\"==t)||/^[\+\-\/]$/.test(t)&&t==i)&&(F+=" ",w++,D++),S=!1}var o=e.split(/\r?\n/),u=o.length-1;E+=u,0==u?w+=o[u].length:w=o[u].length,D+=e.length,k=e,F+=e}function p(){C=!1,f(";")}function d(){return A+n.indent_level}function h(n){var e;return f("{"),M(),O(d(),function(){e=n()}),$(),f("}"),e}function _(n){f("(");var e=n();return f(")"),e}function m(n){f("[");var e=n();return f("]"),e}function v(){f(","),B()}function b(){f(":"),n.space_colon&&B()}function y(){return F}n=c(n,{indent_start:0,indent_level:4,quote_keys:!1,space_colon:!0,ascii_only:!1,unescape_regexps:!1,inline_script:!1,width:80,max_line_len:32e3,beautify:!1,source_map:null,bracketize:!1,semicolons:!0,comments:!1,preserve_line:!1,screw_ie8:!1,preamble:null},!0);var A=0,w=0,E=1,D=0,F="",S=!1,C=!1,k=null,x=g("( [ + * / - , ."),B=n.beautify?function(){f(" ")}:function(){S=!0},$=n.beautify?function(e){n.beautify&&f(o(e?.5:0))}:l,O=n.beautify?function(n,e){n===!0&&(n=d());var t=A;A=n;var r=e();return A=t,r}:function(n,e){return e()},M=n.beautify?function(){f("\n")}:l,N=n.beautify?function(){f(";")}:function(){C=!0},R=n.source_map?function(e,t){try{e&&n.source_map.add(e.file||"?",E,w,e.line,e.col,t||"name"!=e.type?t:e.value)}catch(r){W.warn("Couldn't figure out mapping for {file}:{line},{col} → {cline},{ccol} [{name}]",{file:e.file,line:e.line,col:e.col,cline:E,ccol:w,name:t||""})}}:l;n.preamble&&f(n.preamble.replace(/\r\n?|[\n\u2028\u2029]|\s*$/g,"\n"));var q=[];return{get:y,toString:y,indent:$,indentation:function(){return A},current_width:function(){return w-A},should_break:function(){return n.width&&this.current_width()>=n.width},newline:M,print:f,space:B,comma:v,colon:b,last:function(){return k},semicolon:N,force_semicolon:p,to_ascii:e,print_name:function(n){f(i(n))},print_string:function(n){f(r(n))},next_indent:d,with_indent:O,with_block:h,with_parens:_,with_square:m,add_mapping:R,option:function(e){return n[e]},line:function(){return E},col:function(){return w},pos:function(){return D},push_node:function(n){q.push(n)},pop_node:function(){return q.pop()},stack:function(){return q},parent:function(n){return q[q.length-2-(n||0)]}}}function I(n,e){return this instanceof I?(z.call(this,this.before,this.after),void(this.options=c(n,{sequences:!e,properties:!e,dead_code:!e,drop_debugger:!e,unsafe:!1,unsafe_comps:!1,conditionals:!e,comparisons:!e,evaluate:!e,booleans:!e,loops:!e,unused:!e,hoist_funs:!e,keep_fargs:!1,hoist_vars:!1,if_return:!e,join_vars:!e,cascade:!e,side_effects:!e,pure_getters:!1,pure_funcs:null,negate_iife:!e,screw_ie8:!1,drop_console:!1,angular:!1,warnings:!0,global_defs:{}},!0))):new I(n,e)}function U(n){function e(e,i,o,a,u,s){if(r){var c=r.originalPositionFor({line:a,column:u});if(null===c.source)return;e=c.source,a=c.line,u=c.column,s=c.name}t.addMapping({generated:{line:i+n.dest_line_diff,column:o},original:{line:a+n.orig_line_diff,column:u},source:e,name:s})}n=c(n,{file:null,root:null,orig:null,orig_line_diff:0,dest_line_diff:0});var t=new MOZ_SourceMap.SourceMapGenerator({file:n.file,sourceRoot:n.root}),r=n.orig&&new MOZ_SourceMap.SourceMapConsumer(n.orig);return{add:e,get:function(){return t},toString:function(){return t.toString()}}}e.UglifyJS=n,s.prototype=Object.create(Error.prototype),s.prototype.constructor=s,s.croak=function(n,e){throw new s(n,e)};var V=function(){function n(n,o,a){function u(){var u=o(n[s],s),l=u instanceof r;return l&&(u=u.v),u instanceof e?(u=u.v,u instanceof t?f.push.apply(f,a?u.v.slice().reverse():u.v):f.push(u)):u!==i&&(u instanceof t?c.push.apply(c,a?u.v.slice().reverse():u.v):c.push(u)),l}var s,c=[],f=[];if(n instanceof Array)if(a){for(s=n.length;--s>=0&&!u(););c.reverse(),f.reverse()}else for(s=0;s SymbolDef for all variables/functions defined in this scope",functions:"[Object/S] like `variables`, but only lists function declarations",uses_with:"[boolean/S] tells whether this scope uses the `with` statement",uses_eval:"[boolean/S] tells whether this scope contains a direct call to the global `eval`",parent_scope:"[AST_Scope?/S] link to the parent scope",enclosed:"[SymbolDef*/S] a list of all symbol definitions that are accessed from this scope or any subscopes",cname:"[integer/S] current index for mangling variables (used internally by the mangler)"}},J),fe=A("Toplevel","globals",{$documentation:"The toplevel scope",$propdoc:{globals:"[Object/S] a map of name -> SymbolDef for all undeclared names"},wrap_enclose:function(n){var e=this,t=[],r=[];n.forEach(function(n){var e=n.lastIndexOf(":");t.push(n.substr(0,e)),r.push(n.substr(e+1))});var i="(function("+r.join(",")+"){ '$ORIG'; })("+t.join(",")+")";return i=H(i),i=i.transform(new z(function(n){return n instanceof G&&"$ORIG"==n.value?V.splice(e.body):void 0}))},wrap_commonjs:function(n,e){var t=this,r=[];e&&(t.figure_out_scope(),t.walk(new E(function(n){n instanceof nt&&n.definition().global&&(a(function(e){return e.name==n.name},r)||r.push(n))})));var i="(function(exports, global){ global['"+n+"'] = exports; '$ORIG'; '$EXPORTS'; }({}, (function(){return this}())))";return i=H(i),i=i.transform(new z(function(n){if(n instanceof K&&(n=n.body,n instanceof pt))switch(n.getValue()){case"$ORIG":return V.splice(t.body);case"$EXPORTS":var e=[];return r.forEach(function(n){e.push(new K({body:new Le({left:new ze({expression:new st({name:"exports"}),property:new pt({value:n.name})}),operator:"=",right:new st(n)})}))}),V.splice(e)}}))}},ce),le=A("Lambda","name argnames uses_arguments",{$documentation:"Base class for functions",$propdoc:{name:"[AST_SymbolDeclaration?] the name of this function",argnames:"[AST_SymbolFunarg*] array of function arguments",uses_arguments:"[boolean/S] tells whether this function accesses the arguments array"},_walk:function(n){return n._visit(this,function(){this.name&&this.name._walk(n),this.argnames.forEach(function(e){e._walk(n)}),w(this,n)})}},ce),pe=A("Accessor",null,{$documentation:"A setter/getter function. The `name` property is always null."},le),de=A("Function",null,{$documentation:"A function expression"},le),he=A("Defun",null,{$documentation:"A function definition"},le),_e=A("Jump",null,{$documentation:"Base class for “jumps” (for now that's `return`, `throw`, `break` and `continue`)"},Y),me=A("Exit","value",{$documentation:"Base class for “exits” (`return` and `throw`)",$propdoc:{value:"[AST_Node?] the value returned or thrown by this statement; could be null for AST_Return"},_walk:function(n){return n._visit(this,this.value&&function(){this.value._walk(n)})}},_e),ve=A("Return",null,{$documentation:"A `return` statement"},me),ge=A("Throw",null,{$documentation:"A `throw` statement"},me),be=A("LoopControl","label",{$documentation:"Base class for loop control statements (`break` and `continue`)",$propdoc:{label:"[AST_LabelRef?] the label, or null if none"},_walk:function(n){return n._visit(this,this.label&&function(){this.label._walk(n)})}},_e),ye=A("Break",null,{$documentation:"A `break` statement"},be),Ae=A("Continue",null,{$documentation:"A `continue` statement"},be),we=A("If","condition alternative",{$documentation:"A `if` statement",$propdoc:{condition:"[AST_Node] the `if` condition",alternative:"[AST_Statement?] the `else` part, or null if not present"},_walk:function(n){return n._visit(this,function(){this.condition._walk(n),this.body._walk(n),this.alternative&&this.alternative._walk(n)})}},ne),Ee=A("Switch","expression",{$documentation:"A `switch` statement",$propdoc:{expression:"[AST_Node] the `switch` “discriminant”"},_walk:function(n){return n._visit(this,function(){this.expression._walk(n),w(this,n)})}},J),De=A("SwitchBranch",null,{$documentation:"Base class for `switch` branches"},J),Fe=A("Default",null,{$documentation:"A `default` switch branch"},De),Se=A("Case","expression",{$documentation:"A `case` switch branch",$propdoc:{expression:"[AST_Node] the `case` expression"},_walk:function(n){return n._visit(this,function(){this.expression._walk(n),w(this,n)})}},De),Ce=A("Try","bcatch bfinally",{$documentation:"A `try` statement",$propdoc:{bcatch:"[AST_Catch?] the catch block, or null if not present",bfinally:"[AST_Finally?] the finally block, or null if not present"},_walk:function(n){return n._visit(this,function(){w(this,n),this.bcatch&&this.bcatch._walk(n),this.bfinally&&this.bfinally._walk(n)})}},J),ke=A("Catch","argname",{$documentation:"A `catch` node; only makes sense as part of a `try` statement",$propdoc:{argname:"[AST_SymbolCatch] symbol for the exception"},_walk:function(n){return n._visit(this,function(){this.argname._walk(n),w(this,n) +})}},J),xe=A("Finally",null,{$documentation:"A `finally` node; only makes sense as part of a `try` statement"},J),Be=A("Definitions","definitions",{$documentation:"Base class for `var` or `const` nodes (variable declarations/initializations)",$propdoc:{definitions:"[AST_VarDef*] array of variable definitions"},_walk:function(n){return n._visit(this,function(){this.definitions.forEach(function(e){e._walk(n)})})}},Y),Te=A("Var",null,{$documentation:"A `var` statement"},Be),$e=A("Const",null,{$documentation:"A `const` statement"},Be),Oe=A("VarDef","name value",{$documentation:"A variable declaration; only appears in a AST_Definitions node",$propdoc:{name:"[AST_SymbolVar|AST_SymbolConst] name of the variable",value:"[AST_Node?] initializer, or null of there's no initializer"},_walk:function(n){return n._visit(this,function(){this.name._walk(n),this.value&&this.value._walk(n)})}}),Me=A("Call","expression args",{$documentation:"A function call expression",$propdoc:{expression:"[AST_Node] expression to invoke as function",args:"[AST_Node*] array of arguments"},_walk:function(n){return n._visit(this,function(){this.expression._walk(n),this.args.forEach(function(e){e._walk(n)})})}}),Ne=A("New",null,{$documentation:"An object instantiation. Derives from a function call since it has exactly the same properties"},Me),Re=A("Seq","car cdr",{$documentation:"A sequence expression (two comma-separated expressions)",$propdoc:{car:"[AST_Node] first element in sequence",cdr:"[AST_Node] second element in sequence"},$cons:function(n,e){var t=new Re(n);return t.car=n,t.cdr=e,t},$from_array:function(n){if(0==n.length)return null;if(1==n.length)return n[0].clone();for(var e=null,t=n.length;--t>=0;)e=Re.cons(n[t],e);for(var r=e;r;){if(r.cdr&&!r.cdr.cdr){r.cdr=r.cdr.car;break}r=r.cdr}return e},to_array:function(){for(var n=this,e=[];n;){if(e.push(n.car),n.cdr&&!(n.cdr instanceof Re)){e.push(n.cdr);break}n=n.cdr}return e},add:function(n){for(var e=this;e;){if(!(e.cdr instanceof Re)){var t=Re.cons(e.cdr,n);return e.cdr=t}e=e.cdr}},_walk:function(n){return n._visit(this,function(){this.car._walk(n),this.cdr&&this.cdr._walk(n)})}}),qe=A("PropAccess","expression property",{$documentation:'Base class for property access expressions, i.e. `a.foo` or `a["foo"]`',$propdoc:{expression:"[AST_Node] the “container” expression",property:"[AST_Node|string] the property to access. For AST_Dot this is always a plain string, while for AST_Sub it's an arbitrary AST_Node"}}),He=A("Dot",null,{$documentation:"A dotted property access expression",_walk:function(n){return n._visit(this,function(){this.expression._walk(n)})}},qe),ze=A("Sub",null,{$documentation:'Index-style property access, i.e. `a["foo"]`',_walk:function(n){return n._visit(this,function(){this.expression._walk(n),this.property._walk(n)})}},qe),Pe=A("Unary","operator expression",{$documentation:"Base class for unary expressions",$propdoc:{operator:"[string] the operator",expression:"[AST_Node] expression that this unary operator applies to"},_walk:function(n){return n._visit(this,function(){this.expression._walk(n)})}}),je=A("UnaryPrefix",null,{$documentation:"Unary prefix expression, i.e. `typeof i` or `++i`"},Pe),Ie=A("UnaryPostfix",null,{$documentation:"Unary postfix expression, i.e. `i++`"},Pe),Ue=A("Binary","left operator right",{$documentation:"Binary expression, i.e. `a + b`",$propdoc:{left:"[AST_Node] left-hand side expression",operator:"[string] the operator",right:"[AST_Node] right-hand side expression"},_walk:function(n){return n._visit(this,function(){this.left._walk(n),this.right._walk(n)})}}),Ve=A("Conditional","condition consequent alternative",{$documentation:"Conditional expression using the ternary operator, i.e. `a ? b : c`",$propdoc:{condition:"[AST_Node]",consequent:"[AST_Node]",alternative:"[AST_Node]"},_walk:function(n){return n._visit(this,function(){this.condition._walk(n),this.consequent._walk(n),this.alternative._walk(n)})}}),Le=A("Assign",null,{$documentation:"An assignment expression — `a = b + 5`"},Ue),We=A("Array","elements",{$documentation:"An array literal",$propdoc:{elements:"[AST_Node*] array of elements"},_walk:function(n){return n._visit(this,function(){this.elements.forEach(function(e){e._walk(n)})})}}),Ye=A("Object","properties",{$documentation:"An object literal",$propdoc:{properties:"[AST_ObjectProperty*] array of properties"},_walk:function(n){return n._visit(this,function(){this.properties.forEach(function(e){e._walk(n)})})}}),Xe=A("ObjectProperty","key value",{$documentation:"Base class for literal object properties",$propdoc:{key:"[string] the property name converted to a string for ObjectKeyVal. For setters and getters this is an arbitrary AST_Node.",value:"[AST_Node] property value. For setters and getters this is an AST_Function."},_walk:function(n){return n._visit(this,function(){this.value._walk(n)})}}),Ge=A("ObjectKeyVal",null,{$documentation:"A key: value object property"},Xe),Ke=A("ObjectSetter",null,{$documentation:"An object setter property"},Xe),Je=A("ObjectGetter",null,{$documentation:"An object getter property"},Xe),Ze=A("Symbol","scope name thedef",{$propdoc:{name:"[string] name of this symbol",scope:"[AST_Scope/S] the current scope (not necessarily the definition scope)",thedef:"[SymbolDef/S] the definition of this symbol"},$documentation:"Base class for all symbols"}),Qe=A("SymbolAccessor",null,{$documentation:"The name of a property accessor (setter/getter function)"},Ze),nt=A("SymbolDeclaration","init",{$documentation:"A declaration symbol (symbol in var/const, function name or argument, symbol in catch)",$propdoc:{init:"[AST_Node*/S] array of initializers for this declaration."}},Ze),et=A("SymbolVar",null,{$documentation:"Symbol defining a variable"},nt),tt=A("SymbolConst",null,{$documentation:"A constant declaration"},nt),rt=A("SymbolFunarg",null,{$documentation:"Symbol naming a function argument"},et),it=A("SymbolDefun",null,{$documentation:"Symbol defining a function"},nt),ot=A("SymbolLambda",null,{$documentation:"Symbol naming a function expression"},nt),at=A("SymbolCatch",null,{$documentation:"Symbol naming the exception in catch"},nt),ut=A("Label","references",{$documentation:"Symbol naming a label (declaration)",$propdoc:{references:"[AST_LoopControl*] a list of nodes referring to this label"},initialize:function(){this.references=[],this.thedef=this}},Ze),st=A("SymbolRef",null,{$documentation:"Reference to some symbol (not definition/declaration)"},Ze),ct=A("LabelRef",null,{$documentation:"Reference to a label symbol"},Ze),ft=A("This",null,{$documentation:"The `this` symbol"},Ze),lt=A("Constant",null,{$documentation:"Base class for all constants",getValue:function(){return this.value}}),pt=A("String","value",{$documentation:"A string literal",$propdoc:{value:"[string] the contents of this string"}},lt),dt=A("Number","value",{$documentation:"A number literal",$propdoc:{value:"[number] the numeric value"}},lt),ht=A("RegExp","value",{$documentation:"A regexp literal",$propdoc:{value:"[RegExp] the actual regexp"}},lt),_t=A("Atom",null,{$documentation:"Base class for atoms"},lt),mt=A("Null",null,{$documentation:"The `null` atom",value:null},_t),vt=A("NaN",null,{$documentation:"The impossible value",value:0/0},_t),gt=A("Undefined",null,{$documentation:"The `undefined` value",value:void 0},_t),bt=A("Hole",null,{$documentation:"A hole in an array",value:void 0},_t),yt=A("Infinity",null,{$documentation:"The `Infinity` value",value:1/0},_t),At=A("Boolean",null,{$documentation:"Base class for booleans"},_t),wt=A("False",null,{$documentation:"The `false` atom",value:!1},At),Et=A("True",null,{$documentation:"The `true` atom",value:!0},At);E.prototype={_visit:function(n,e){this.stack.push(n);var t=this.visit(n,e?function(){e.call(n)}:l);return!t&&e&&e.call(n),this.stack.pop(),t},parent:function(n){return this.stack[this.stack.length-2-(n||0)]},push:function(n){this.stack.push(n)},pop:function(){return this.stack.pop()},self:function(){return this.stack[this.stack.length-1]},find_parent:function(n){for(var e=this.stack,t=e.length;--t>=0;){var r=e[t];if(r instanceof n)return r}},has_directive:function(n){return this.find_parent(ce).has_directive(n)},in_boolean_context:function(){for(var n=this.stack,e=n.length,t=n[--e];e>0;){var r=n[--e];if(r instanceof we&&r.condition===t||r instanceof Ve&&r.condition===t||r instanceof re&&r.condition===t||r instanceof ae&&r.condition===t||r instanceof je&&"!"==r.operator&&r.expression===t)return!0;if(!(r instanceof Ue)||"&&"!=r.operator&&"||"!=r.operator)return!1;t=r}},loopcontrol_target:function(n){var e=this.stack;if(n)for(var t=e.length;--t>=0;){var r=e[t];if(r instanceof ee&&r.label.name==n.name)return r.body}else for(var t=e.length;--t>=0;){var r=e[t];if(r instanceof Ee||r instanceof te)return r}}};var Dt="break case catch const continue debugger default delete do else finally for function if in instanceof new return switch throw try typeof var void while with",Ft="false null true",St="abstract boolean byte char class double enum export extends final float goto implements import int interface long native package private protected public short static super synchronized this throws transient volatile yield "+Ft+" "+Dt,Ct="return new delete throw else case";Dt=g(Dt),St=g(St),Ct=g(Ct),Ft=g(Ft);var kt=g(i("+-*&%=<>!?|~^")),xt=/^0x[0-9a-f]+$/i,Bt=/^0[0-7]+$/,Tt=/^\d*\.?\d*(?:e[+-]?\d*(?:\d\.?|\.?\d)\d*)?$/i,$t=g(["in","instanceof","typeof","new","void","delete","++","--","+","-","!","~","&","|","^","*","/","%",">>","<<",">>>","<",">","<=",">=","==","===","!=","!==","?","=","+=","-=","/=","*=","%=",">>=","<<=",">>>=","|=","^=","&=","&&","||"]),Ot=g(i(" \n\r \f ")),Mt=g(i("[{(,.;:")),Nt=g(i("[]{}(),;:")),Rt=g(i("gmsiy")),qt={letter:new RegExp("[\\u0041-\\u005A\\u0061-\\u007A\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u0523\\u0531-\\u0556\\u0559\\u0561-\\u0587\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0621-\\u064A\\u066E\\u066F\\u0671-\\u06D3\\u06D5\\u06E5\\u06E6\\u06EE\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4\\u07F5\\u07FA\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971\\u0972\\u097B-\\u097F\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0\\u0AE1\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C33\\u0C35-\\u0C39\\u0C3D\\u0C58\\u0C59\\u0C60\\u0C61\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDE\\u0CE0\\u0CE1\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D28\\u0D2A-\\u0D39\\u0D3D\\u0D60\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E46\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD-\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC\\u0EDD\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8B\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10D0-\\u10FA\\u10FC\\u1100-\\u1159\\u115F-\\u11A2\\u11A8-\\u11F9\\u1200-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F4\\u1401-\\u166C\\u166F-\\u1676\\u1681-\\u169A\\u16A0-\\u16EA\\u1700-\\u170C\\u170E-\\u1711\\u1720-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1877\\u1880-\\u18A8\\u18AA\\u1900-\\u191C\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19A9\\u19C1-\\u19C7\\u1A00-\\u1A16\\u1B05-\\u1B33\\u1B45-\\u1B4B\\u1B83-\\u1BA0\\u1BAE\\u1BAF\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u2094\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2183\\u2184\\u2C00-\\u2C2E\\u2C30-\\u2C5E\\u2C60-\\u2C6F\\u2C71-\\u2C7D\\u2C80-\\u2CE4\\u2D00-\\u2D25\\u2D30-\\u2D65\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005\\u3006\\u3031-\\u3035\\u303B\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u31A0-\\u31B7\\u31F0-\\u31FF\\u3400\\u4DB5\\u4E00\\u9FC3\\uA000-\\uA48C\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A\\uA62B\\uA640-\\uA65F\\uA662-\\uA66E\\uA67F-\\uA697\\uA717-\\uA71F\\uA722-\\uA788\\uA78B\\uA78C\\uA7FB-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA90A-\\uA925\\uA930-\\uA946\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAC00\\uD7A3\\uF900-\\uFA2D\\uFA30-\\uFA6A\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC]"),non_spacing_mark:new RegExp("[\\u0300-\\u036F\\u0483-\\u0487\\u0591-\\u05BD\\u05BF\\u05C1\\u05C2\\u05C4\\u05C5\\u05C7\\u0610-\\u061A\\u064B-\\u065E\\u0670\\u06D6-\\u06DC\\u06DF-\\u06E4\\u06E7\\u06E8\\u06EA-\\u06ED\\u0711\\u0730-\\u074A\\u07A6-\\u07B0\\u07EB-\\u07F3\\u0816-\\u0819\\u081B-\\u0823\\u0825-\\u0827\\u0829-\\u082D\\u0900-\\u0902\\u093C\\u0941-\\u0948\\u094D\\u0951-\\u0955\\u0962\\u0963\\u0981\\u09BC\\u09C1-\\u09C4\\u09CD\\u09E2\\u09E3\\u0A01\\u0A02\\u0A3C\\u0A41\\u0A42\\u0A47\\u0A48\\u0A4B-\\u0A4D\\u0A51\\u0A70\\u0A71\\u0A75\\u0A81\\u0A82\\u0ABC\\u0AC1-\\u0AC5\\u0AC7\\u0AC8\\u0ACD\\u0AE2\\u0AE3\\u0B01\\u0B3C\\u0B3F\\u0B41-\\u0B44\\u0B4D\\u0B56\\u0B62\\u0B63\\u0B82\\u0BC0\\u0BCD\\u0C3E-\\u0C40\\u0C46-\\u0C48\\u0C4A-\\u0C4D\\u0C55\\u0C56\\u0C62\\u0C63\\u0CBC\\u0CBF\\u0CC6\\u0CCC\\u0CCD\\u0CE2\\u0CE3\\u0D41-\\u0D44\\u0D4D\\u0D62\\u0D63\\u0DCA\\u0DD2-\\u0DD4\\u0DD6\\u0E31\\u0E34-\\u0E3A\\u0E47-\\u0E4E\\u0EB1\\u0EB4-\\u0EB9\\u0EBB\\u0EBC\\u0EC8-\\u0ECD\\u0F18\\u0F19\\u0F35\\u0F37\\u0F39\\u0F71-\\u0F7E\\u0F80-\\u0F84\\u0F86\\u0F87\\u0F90-\\u0F97\\u0F99-\\u0FBC\\u0FC6\\u102D-\\u1030\\u1032-\\u1037\\u1039\\u103A\\u103D\\u103E\\u1058\\u1059\\u105E-\\u1060\\u1071-\\u1074\\u1082\\u1085\\u1086\\u108D\\u109D\\u135F\\u1712-\\u1714\\u1732-\\u1734\\u1752\\u1753\\u1772\\u1773\\u17B7-\\u17BD\\u17C6\\u17C9-\\u17D3\\u17DD\\u180B-\\u180D\\u18A9\\u1920-\\u1922\\u1927\\u1928\\u1932\\u1939-\\u193B\\u1A17\\u1A18\\u1A56\\u1A58-\\u1A5E\\u1A60\\u1A62\\u1A65-\\u1A6C\\u1A73-\\u1A7C\\u1A7F\\u1B00-\\u1B03\\u1B34\\u1B36-\\u1B3A\\u1B3C\\u1B42\\u1B6B-\\u1B73\\u1B80\\u1B81\\u1BA2-\\u1BA5\\u1BA8\\u1BA9\\u1C2C-\\u1C33\\u1C36\\u1C37\\u1CD0-\\u1CD2\\u1CD4-\\u1CE0\\u1CE2-\\u1CE8\\u1CED\\u1DC0-\\u1DE6\\u1DFD-\\u1DFF\\u20D0-\\u20DC\\u20E1\\u20E5-\\u20F0\\u2CEF-\\u2CF1\\u2DE0-\\u2DFF\\u302A-\\u302F\\u3099\\u309A\\uA66F\\uA67C\\uA67D\\uA6F0\\uA6F1\\uA802\\uA806\\uA80B\\uA825\\uA826\\uA8C4\\uA8E0-\\uA8F1\\uA926-\\uA92D\\uA947-\\uA951\\uA980-\\uA982\\uA9B3\\uA9B6-\\uA9B9\\uA9BC\\uAA29-\\uAA2E\\uAA31\\uAA32\\uAA35\\uAA36\\uAA43\\uAA4C\\uAAB0\\uAAB2-\\uAAB4\\uAAB7\\uAAB8\\uAABE\\uAABF\\uAAC1\\uABE5\\uABE8\\uABED\\uFB1E\\uFE00-\\uFE0F\\uFE20-\\uFE26]"),space_combining_mark:new RegExp("[\\u0903\\u093E-\\u0940\\u0949-\\u094C\\u094E\\u0982\\u0983\\u09BE-\\u09C0\\u09C7\\u09C8\\u09CB\\u09CC\\u09D7\\u0A03\\u0A3E-\\u0A40\\u0A83\\u0ABE-\\u0AC0\\u0AC9\\u0ACB\\u0ACC\\u0B02\\u0B03\\u0B3E\\u0B40\\u0B47\\u0B48\\u0B4B\\u0B4C\\u0B57\\u0BBE\\u0BBF\\u0BC1\\u0BC2\\u0BC6-\\u0BC8\\u0BCA-\\u0BCC\\u0BD7\\u0C01-\\u0C03\\u0C41-\\u0C44\\u0C82\\u0C83\\u0CBE\\u0CC0-\\u0CC4\\u0CC7\\u0CC8\\u0CCA\\u0CCB\\u0CD5\\u0CD6\\u0D02\\u0D03\\u0D3E-\\u0D40\\u0D46-\\u0D48\\u0D4A-\\u0D4C\\u0D57\\u0D82\\u0D83\\u0DCF-\\u0DD1\\u0DD8-\\u0DDF\\u0DF2\\u0DF3\\u0F3E\\u0F3F\\u0F7F\\u102B\\u102C\\u1031\\u1038\\u103B\\u103C\\u1056\\u1057\\u1062-\\u1064\\u1067-\\u106D\\u1083\\u1084\\u1087-\\u108C\\u108F\\u109A-\\u109C\\u17B6\\u17BE-\\u17C5\\u17C7\\u17C8\\u1923-\\u1926\\u1929-\\u192B\\u1930\\u1931\\u1933-\\u1938\\u19B0-\\u19C0\\u19C8\\u19C9\\u1A19-\\u1A1B\\u1A55\\u1A57\\u1A61\\u1A63\\u1A64\\u1A6D-\\u1A72\\u1B04\\u1B35\\u1B3B\\u1B3D-\\u1B41\\u1B43\\u1B44\\u1B82\\u1BA1\\u1BA6\\u1BA7\\u1BAA\\u1C24-\\u1C2B\\u1C34\\u1C35\\u1CE1\\u1CF2\\uA823\\uA824\\uA827\\uA880\\uA881\\uA8B4-\\uA8C3\\uA952\\uA953\\uA983\\uA9B4\\uA9B5\\uA9BA\\uA9BB\\uA9BD-\\uA9C0\\uAA2F\\uAA30\\uAA33\\uAA34\\uAA4D\\uAA7B\\uABE3\\uABE4\\uABE6\\uABE7\\uABE9\\uABEA\\uABEC]"),connector_punctuation:new RegExp("[\\u005F\\u203F\\u2040\\u2054\\uFE33\\uFE34\\uFE4D-\\uFE4F\\uFF3F]")};M.prototype.toString=function(){return this.message+" (line: "+this.line+", col: "+this.col+", pos: "+this.pos+")\n\n"+this.stack};var Ht={},zt=g(["typeof","void","delete","--","++","!","~","-","+"]),Pt=g(["--","++"]),jt=g(["=","+=","-=","/=","*=","%=",">>=","<<=",">>>=","|=","^=","&="]),It=function(n,e){for(var t=0;t","<=",">=","in","instanceof"],[">>","<<",">>>"],["+","-"],["*","/","%"]],{}),Ut=t(["for","do","while","switch"]),Vt=t(["atom","num","string","regexp","name"]);z.prototype=new E,function(n){function e(e,t){e.DEFMETHOD("transform",function(e,r){var i,o;return e.push(this),e.before&&(i=e.before(this,t,r)),i===n&&(e.after?(e.stack[e.stack.length-1]=i=this.clone(),t(i,e),o=e.after(i,r),o!==n&&(i=o)):(i=this,t(i,e))),e.pop(),i})}function t(n,e){return V(n,function(n){return n.transform(e,!0)})}e(W,l),e(ee,function(n,e){n.label=n.label.transform(e),n.body=n.body.transform(e)}),e(K,function(n,e){n.body=n.body.transform(e)}),e(J,function(n,e){n.body=t(n.body,e)}),e(re,function(n,e){n.condition=n.condition.transform(e),n.body=n.body.transform(e)}),e(ae,function(n,e){n.init&&(n.init=n.init.transform(e)),n.condition&&(n.condition=n.condition.transform(e)),n.step&&(n.step=n.step.transform(e)),n.body=n.body.transform(e)}),e(ue,function(n,e){n.init=n.init.transform(e),n.object=n.object.transform(e),n.body=n.body.transform(e)}),e(se,function(n,e){n.expression=n.expression.transform(e),n.body=n.body.transform(e)}),e(me,function(n,e){n.value&&(n.value=n.value.transform(e))}),e(be,function(n,e){n.label&&(n.label=n.label.transform(e))}),e(we,function(n,e){n.condition=n.condition.transform(e),n.body=n.body.transform(e),n.alternative&&(n.alternative=n.alternative.transform(e))}),e(Ee,function(n,e){n.expression=n.expression.transform(e),n.body=t(n.body,e)}),e(Se,function(n,e){n.expression=n.expression.transform(e),n.body=t(n.body,e)}),e(Ce,function(n,e){n.body=t(n.body,e),n.bcatch&&(n.bcatch=n.bcatch.transform(e)),n.bfinally&&(n.bfinally=n.bfinally.transform(e))}),e(ke,function(n,e){n.argname=n.argname.transform(e),n.body=t(n.body,e)}),e(Be,function(n,e){n.definitions=t(n.definitions,e)}),e(Oe,function(n,e){n.name=n.name.transform(e),n.value&&(n.value=n.value.transform(e))}),e(le,function(n,e){n.name&&(n.name=n.name.transform(e)),n.argnames=t(n.argnames,e),n.body=t(n.body,e)}),e(Me,function(n,e){n.expression=n.expression.transform(e),n.args=t(n.args,e)}),e(Re,function(n,e){n.car=n.car.transform(e),n.cdr=n.cdr.transform(e)}),e(He,function(n,e){n.expression=n.expression.transform(e)}),e(ze,function(n,e){n.expression=n.expression.transform(e),n.property=n.property.transform(e)}),e(Pe,function(n,e){n.expression=n.expression.transform(e)}),e(Ue,function(n,e){n.left=n.left.transform(e),n.right=n.right.transform(e)}),e(Ve,function(n,e){n.condition=n.condition.transform(e),n.consequent=n.consequent.transform(e),n.alternative=n.alternative.transform(e)}),e(We,function(n,e){n.elements=t(n.elements,e)}),e(Ye,function(n,e){n.properties=t(n.properties,e)}),e(Xe,function(n,e){n.value=n.value.transform(e)})}(),P.prototype={unmangleable:function(n){return this.global&&!(n&&n.toplevel)||this.undeclared||!(n&&n.eval)&&(this.scope.uses_eval||this.scope.uses_with)},mangle:function(n){if(!this.mangled_name&&!this.unmangleable(n)){var e=this.scope;!n.screw_ie8&&this.orig[0]instanceof ot&&(e=e.parent_scope),this.mangled_name=e.next_mangled(n,this)}}},fe.DEFMETHOD("figure_out_scope",function(n){n=c(n,{screw_ie8:!1});var e=this,t=e.parent_scope=null,r=null,i=0,o=new E(function(e,a){if(n.screw_ie8&&e instanceof ke){var u=t;return t=new ce(e),t.init_scope_vars(i),t.parent_scope=u,a(),t=u,!0}if(e instanceof ce){e.init_scope_vars(i);var u=e.parent_scope=t,s=r;return r=t=e,++i,a(),--i,t=u,r=s,!0}if(e instanceof G)return e.scope=t,p(t.directives,e.value),!0;if(e instanceof se)for(var c=t;c;c=c.parent_scope)c.uses_with=!0;else if(e instanceof Ze&&(e.scope=t),e instanceof ot)r.def_function(e);else if(e instanceof it)(e.scope=r.parent_scope).def_function(e);else if(e instanceof et||e instanceof tt){var f=r.def_variable(e);f.constant=e instanceof tt,f.init=o.parent().value}else e instanceof at&&(n.screw_ie8?t:r).def_variable(e)});e.walk(o);var a=null,u=e.globals=new y,o=new E(function(n,t){if(n instanceof le){var r=a;return a=n,t(),a=r,!0}if(n instanceof st){var i=n.name,s=n.scope.find_variable(i);if(s)n.thedef=s;else{var c;if(u.has(i)?c=u.get(i):(c=new P(e,u.size(),n),c.undeclared=!0,c.global=!0,u.set(i,c)),n.thedef=c,"eval"==i&&o.parent()instanceof Me)for(var f=n.scope;f&&!f.uses_eval;f=f.parent_scope)f.uses_eval=!0;a&&"arguments"==i&&(a.uses_arguments=!0)}return n.reference(),!0}});e.walk(o)}),ce.DEFMETHOD("init_scope_vars",function(n){this.directives=[],this.variables=new y,this.functions=new y,this.uses_with=!1,this.uses_eval=!1,this.parent_scope=null,this.enclosed=[],this.cname=-1,this.nesting=n}),ce.DEFMETHOD("strict",function(){return this.has_directive("use strict")}),le.DEFMETHOD("init_scope_vars",function(){ce.prototype.init_scope_vars.apply(this,arguments),this.uses_arguments=!1}),st.DEFMETHOD("reference",function(){var n=this.definition();n.references.push(this);for(var e=this.scope;e&&(p(e.enclosed,n),e!==n.scope);)e=e.parent_scope;this.frame=this.scope.nesting-n.scope.nesting}),ce.DEFMETHOD("find_variable",function(n){return n instanceof Ze&&(n=n.name),this.variables.get(n)||this.parent_scope&&this.parent_scope.find_variable(n)}),ce.DEFMETHOD("has_directive",function(n){return this.parent_scope&&this.parent_scope.has_directive(n)||(this.directives.indexOf(n)>=0?this:null)}),ce.DEFMETHOD("def_function",function(n){this.functions.set(n.name,this.def_variable(n))}),ce.DEFMETHOD("def_variable",function(n){var e;return this.variables.has(n.name)?(e=this.variables.get(n.name),e.orig.push(n)):(e=new P(this,this.variables.size(),n),this.variables.set(n.name,e),e.global=!this.parent_scope),n.thedef=e}),ce.DEFMETHOD("next_mangled",function(n){var e=this.enclosed;n:for(;;){var t=Lt(++this.cname);if(x(t)&&!(n.except.indexOf(t)>=0)){for(var r=e.length;--r>=0;){var i=e[r],o=i.mangled_name||i.unmangleable(n)&&i.name;if(t==o)continue n}return t}}}),de.DEFMETHOD("next_mangled",function(n,e){for(var t=e.orig[0]instanceof rt&&this.name&&this.name.definition();;){var r=le.prototype.next_mangled.call(this,n,e);if(!t||t.mangled_name!=r)return r}}),ce.DEFMETHOD("references",function(n){return n instanceof Ze&&(n=n.definition()),this.enclosed.indexOf(n)<0?null:n}),Ze.DEFMETHOD("unmangleable",function(n){return this.definition().unmangleable(n)}),Qe.DEFMETHOD("unmangleable",function(){return!0}),ut.DEFMETHOD("unmangleable",function(){return!1}),Ze.DEFMETHOD("unreferenced",function(){return 0==this.definition().references.length&&!(this.scope.uses_eval||this.scope.uses_with)}),Ze.DEFMETHOD("undeclared",function(){return this.definition().undeclared}),ct.DEFMETHOD("undeclared",function(){return!1}),ut.DEFMETHOD("undeclared",function(){return!1}),Ze.DEFMETHOD("definition",function(){return this.thedef}),Ze.DEFMETHOD("global",function(){return this.definition().global}),fe.DEFMETHOD("_default_mangler_options",function(n){return c(n,{except:[],eval:!1,sort:!1,toplevel:!1,screw_ie8:!1})}),fe.DEFMETHOD("mangle_names",function(n){n=this._default_mangler_options(n);var e=-1,t=[],r=new E(function(i,o){if(i instanceof ee){var a=e;return o(),e=a,!0}if(i instanceof ce){var u=(r.parent(),[]);return i.variables.each(function(e){n.except.indexOf(e.name)<0&&u.push(e)}),n.sort&&u.sort(function(n,e){return e.references.length-n.references.length}),void t.push.apply(t,u)}if(i instanceof ut){var s;do s=Lt(++e);while(!x(s));return i.mangled_name=s,!0}return n.screw_ie8&&i instanceof at?void t.push(i.definition()):void 0});this.walk(r),t.forEach(function(e){e.mangle(n)})}),fe.DEFMETHOD("compute_char_frequency",function(n){n=this._default_mangler_options(n);var e=new E(function(e){e instanceof lt?Lt.consider(e.print_to_string()):e instanceof ve?Lt.consider("return"):e instanceof ge?Lt.consider("throw"):e instanceof Ae?Lt.consider("continue"):e instanceof ye?Lt.consider("break"):e instanceof X?Lt.consider("debugger"):e instanceof G?Lt.consider(e.value):e instanceof oe?Lt.consider("while"):e instanceof ie?Lt.consider("do while"):e instanceof we?(Lt.consider("if"),e.alternative&&Lt.consider("else")):e instanceof Te?Lt.consider("var"):e instanceof $e?Lt.consider("const"):e instanceof le?Lt.consider("function"):e instanceof ae?Lt.consider("for"):e instanceof ue?Lt.consider("for in"):e instanceof Ee?Lt.consider("switch"):e instanceof Se?Lt.consider("case"):e instanceof Fe?Lt.consider("default"):e instanceof se?Lt.consider("with"):e instanceof Ke?Lt.consider("set"+e.key):e instanceof Je?Lt.consider("get"+e.key):e instanceof Ge?Lt.consider(e.key):e instanceof Ne?Lt.consider("new"):e instanceof ft?Lt.consider("this"):e instanceof Ce?Lt.consider("try"):e instanceof ke?Lt.consider("catch"):e instanceof xe?Lt.consider("finally"):e instanceof Ze&&e.unmangleable(n)?Lt.consider(e.name):e instanceof Pe||e instanceof Ue?Lt.consider(e.operator):e instanceof He&&Lt.consider(e.property)});this.walk(e),Lt.sort()});var Lt=function(){function n(){r=Object.create(null),t=i.split("").map(function(n){return n.charCodeAt(0)}),t.forEach(function(n){r[n]=0})}function e(n){var e="",r=54;do e+=String.fromCharCode(t[n%r]),n=Math.floor(n/r),r=64;while(n>0);return e}var t,r,i="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_0123456789";return e.consider=function(n){for(var e=n.length;--e>=0;){var t=n.charCodeAt(e);t in r&&++r[t]}},e.sort=function(){t=_(t,function(n,e){return F(n)&&!F(e)?1:F(e)&&!F(n)?-1:r[e]-r[n]})},e.reset=n,n(),e.get=function(){return t},e.freq=function(){return r},e}();fe.DEFMETHOD("scope_warnings",function(n){n=c(n,{undeclared:!1,unreferenced:!0,assign_to_global:!0,func_arguments:!0,nested_defuns:!0,eval:!0});var e=new E(function(t){if(n.undeclared&&t instanceof st&&t.undeclared()&&W.warn("Undeclared symbol: {name} [{file}:{line},{col}]",{name:t.name,file:t.start.file,line:t.start.line,col:t.start.col}),n.assign_to_global){var r=null;t instanceof Le&&t.left instanceof st?r=t.left:t instanceof ue&&t.init instanceof st&&(r=t.init),r&&(r.undeclared()||r.global()&&r.scope!==r.definition().scope)&&W.warn("{msg}: {name} [{file}:{line},{col}]",{msg:r.undeclared()?"Accidental global?":"Assignment to global",name:r.name,file:r.start.file,line:r.start.line,col:r.start.col})}n.eval&&t instanceof st&&t.undeclared()&&"eval"==t.name&&W.warn("Eval is used [{file}:{line},{col}]",t.start),n.unreferenced&&(t instanceof nt||t instanceof ut)&&t.unreferenced()&&W.warn("{type} {name} is declared but not referenced [{file}:{line},{col}]",{type:t instanceof ut?"Label":"Symbol",name:t.name,file:t.start.file,line:t.start.line,col:t.start.col}),n.func_arguments&&t instanceof le&&t.uses_arguments&&W.warn("arguments used in function {name} [{file}:{line},{col}]",{name:t.name?t.name.name:"anonymous",file:t.start.file,line:t.start.line,col:t.start.col}),n.nested_defuns&&t instanceof he&&!(e.parent()instanceof ce)&&W.warn('Function {name} declared in nested statement "{type}" [{file}:{line},{col}]',{name:t.name.name,type:e.parent().TYPE,file:t.start.file,line:t.start.line,col:t.start.col})});this.walk(e)}),function(){function n(n,e){n.DEFMETHOD("_codegen",e)}function e(n,e){n.DEFMETHOD("needs_parens",e)}function t(n){var e=n.parent();return e instanceof Pe?!0:e instanceof Ue&&!(e instanceof Le)?!0:e instanceof Me&&e.expression===this?!0:e instanceof Ve&&e.condition===this?!0:e instanceof qe&&e.expression===this?!0:void 0}function r(n,e,t){var r=n.length-1;n.forEach(function(n,i){n instanceof Q||(t.indent(),n.print(t),i==r&&e||(t.newline(),e&&t.newline()))})}function i(n,e){n.length>0?e.with_block(function(){r(n,!1,e)}):e.print("{}")}function o(n,e){if(e.option("bracketize"))return void h(n.body,e);if(!n.body)return e.force_semicolon();if(n.body instanceof ie&&!e.option("screw_ie8"))return void h(n.body,e);for(var t=n.body;;)if(t instanceof we){if(!t.alternative)return void h(n.body,e);t=t.alternative}else{if(!(t instanceof ne))break;t=t.body}s(n.body,e)}function a(n,e,t){if(t)try{n.walk(new E(function(n){if(n instanceof Ue&&"in"==n.operator)throw e})),n.print(e)}catch(r){if(r!==e)throw r;n.print(e,!0)}else n.print(e)}function u(n){return[92,47,46,43,42,63,40,41,91,93,123,125,36,94,58,124,33,10,13,0,65279,8232,8233].indexOf(n)<0}function s(n,e){e.option("bracketize")?!n||n instanceof Q?e.print("{}"):n instanceof Z?n.print(e):e.with_block(function(){e.indent(),n.print(e),e.newline()}):!n||n instanceof Q?e.force_semicolon():n.print(e)}function c(n){for(var e=n.stack(),t=e.length,r=e[--t],i=e[--t];t>0;){if(i instanceof Y&&i.body===r)return!0;if(!(i instanceof Re&&i.car===r||i instanceof Me&&i.expression===r&&!(i instanceof Ne)||i instanceof He&&i.expression===r||i instanceof ze&&i.expression===r||i instanceof Ve&&i.condition===r||i instanceof Ue&&i.left===r||i instanceof Ie&&i.expression===r))return!1;r=i,i=e[--t]}}function f(n,e){return 0==n.args.length&&!e.option("beautify")}function p(n){for(var e=n[0],t=e.length,r=1;r=0?r.push("0x"+n.toString(16).toLowerCase(),"0"+n.toString(8)):r.push("-0x"+(-n).toString(16).toLowerCase(),"-0"+(-n).toString(8)),(e=/^(.*?)(0+)$/.exec(n))&&r.push(e[1]+"e"+e[2].length)):(e=/^0?\.(0+)(.*)$/.exec(n))&&r.push(e[2]+"e-"+(e[1].length+e[2].length),t.substr(t.indexOf("."))),p(r)}function h(n,e){return n instanceof Z?void n.print(e):void e.with_block(function(){e.indent(),n.print(e),e.newline()})}function _(n,e){n.DEFMETHOD("add_source_map",function(n){e(this,n)})}function m(n,e){e.add_mapping(n.start)}W.DEFMETHOD("print",function(n,e){function t(){r.add_comments(n),r.add_source_map(n),i(r,n)}var r=this,i=r._codegen;n.push_node(r),e||r.needs_parens(n)?n.with_parens(t):t(),n.pop_node()}),W.DEFMETHOD("print_to_string",function(n){var e=j(n);return this.print(e),e.get()}),W.DEFMETHOD("add_comments",function(n){var e=n.option("comments"),t=this;if(e){var r=t.start;if(r&&!r._comments_dumped){r._comments_dumped=!0;var i=r.comments_before||[];t instanceof me&&t.value&&t.value.walk(new E(function(n){return n.start&&n.start.comments_before&&(i=i.concat(n.start.comments_before),n.start.comments_before=[]),n instanceof de||n instanceof We||n instanceof Ye?!0:void 0 +})),e.test?i=i.filter(function(n){return e.test(n.value)}):"function"==typeof e&&(i=i.filter(function(n){return e(t,n)})),i.forEach(function(e){/comment[134]/.test(e.type)?(n.print("//"+e.value+"\n"),n.indent()):"comment2"==e.type&&(n.print("/*"+e.value+"*/"),r.nlb?(n.print("\n"),n.indent()):n.space())})}}}),e(W,function(){return!1}),e(de,function(n){return c(n)}),e(Ye,function(n){return c(n)}),e(Pe,function(n){var e=n.parent();return e instanceof qe&&e.expression===this}),e(Re,function(n){var e=n.parent();return e instanceof Me||e instanceof Pe||e instanceof Ue||e instanceof Oe||e instanceof qe||e instanceof We||e instanceof Xe||e instanceof Ve}),e(Ue,function(n){var e=n.parent();if(e instanceof Me&&e.expression===this)return!0;if(e instanceof Pe)return!0;if(e instanceof qe&&e.expression===this)return!0;if(e instanceof Ue){var t=e.operator,r=It[t],i=this.operator,o=It[i];if(r>o||r==o&&this===e.right)return!0}}),e(qe,function(n){var e=n.parent();if(e instanceof Ne&&e.expression===this)try{this.walk(new E(function(n){if(n instanceof Me)throw e}))}catch(t){if(t!==e)throw t;return!0}}),e(Me,function(n){var e,t=n.parent();return t instanceof Ne&&t.expression===this?!0:this.expression instanceof de&&t instanceof qe&&t.expression===this&&(e=n.parent(1))instanceof Le&&e.left===t}),e(Ne,function(n){var e=n.parent();return f(this,n)&&(e instanceof qe||e instanceof Me&&e.expression===this)?!0:void 0}),e(dt,function(n){var e=n.parent();return this.getValue()<0&&e instanceof qe&&e.expression===this?!0:void 0}),e(vt,function(n){var e=n.parent();return e instanceof qe&&e.expression===this?!0:void 0}),e(Le,t),e(Ve,t),n(G,function(n,e){e.print_string(n.value),e.semicolon()}),n(X,function(n,e){e.print("debugger"),e.semicolon()}),ne.DEFMETHOD("_do_print_body",function(n){s(this.body,n)}),n(Y,function(n,e){n.body.print(e),e.semicolon()}),n(fe,function(n,e){r(n.body,!0,e),e.print("")}),n(ee,function(n,e){n.label.print(e),e.colon(),n.body.print(e)}),n(K,function(n,e){n.body.print(e),e.semicolon()}),n(Z,function(n,e){i(n.body,e)}),n(Q,function(n,e){e.semicolon()}),n(ie,function(n,e){e.print("do"),e.space(),n._do_print_body(e),e.space(),e.print("while"),e.space(),e.with_parens(function(){n.condition.print(e)}),e.semicolon()}),n(oe,function(n,e){e.print("while"),e.space(),e.with_parens(function(){n.condition.print(e)}),e.space(),n._do_print_body(e)}),n(ae,function(n,e){e.print("for"),e.space(),e.with_parens(function(){!n.init||n.init instanceof Q?e.print(";"):(n.init instanceof Be?n.init.print(e):a(n.init,e,!0),e.print(";"),e.space()),n.condition?(n.condition.print(e),e.print(";"),e.space()):e.print(";"),n.step&&n.step.print(e)}),e.space(),n._do_print_body(e)}),n(ue,function(n,e){e.print("for"),e.space(),e.with_parens(function(){n.init.print(e),e.space(),e.print("in"),e.space(),n.object.print(e)}),e.space(),n._do_print_body(e)}),n(se,function(n,e){e.print("with"),e.space(),e.with_parens(function(){n.expression.print(e)}),e.space(),n._do_print_body(e)}),le.DEFMETHOD("_do_print",function(n,e){var t=this;e||n.print("function"),t.name&&(n.space(),t.name.print(n)),n.with_parens(function(){t.argnames.forEach(function(e,t){t&&n.comma(),e.print(n)})}),n.space(),i(t.body,n)}),n(le,function(n,e){n._do_print(e)}),me.DEFMETHOD("_do_print",function(n,e){n.print(e),this.value&&(n.space(),this.value.print(n)),n.semicolon()}),n(ve,function(n,e){n._do_print(e,"return")}),n(ge,function(n,e){n._do_print(e,"throw")}),be.DEFMETHOD("_do_print",function(n,e){n.print(e),this.label&&(n.space(),this.label.print(n)),n.semicolon()}),n(ye,function(n,e){n._do_print(e,"break")}),n(Ae,function(n,e){n._do_print(e,"continue")}),n(we,function(n,e){e.print("if"),e.space(),e.with_parens(function(){n.condition.print(e)}),e.space(),n.alternative?(o(n,e),e.space(),e.print("else"),e.space(),s(n.alternative,e)):n._do_print_body(e)}),n(Ee,function(n,e){e.print("switch"),e.space(),e.with_parens(function(){n.expression.print(e)}),e.space(),n.body.length>0?e.with_block(function(){n.body.forEach(function(n,t){t&&e.newline(),e.indent(!0),n.print(e)})}):e.print("{}")}),De.DEFMETHOD("_do_print_body",function(n){this.body.length>0&&(n.newline(),this.body.forEach(function(e){n.indent(),e.print(n),n.newline()}))}),n(Fe,function(n,e){e.print("default:"),n._do_print_body(e)}),n(Se,function(n,e){e.print("case"),e.space(),n.expression.print(e),e.print(":"),n._do_print_body(e)}),n(Ce,function(n,e){e.print("try"),e.space(),i(n.body,e),n.bcatch&&(e.space(),n.bcatch.print(e)),n.bfinally&&(e.space(),n.bfinally.print(e))}),n(ke,function(n,e){e.print("catch"),e.space(),e.with_parens(function(){n.argname.print(e)}),e.space(),i(n.body,e)}),n(xe,function(n,e){e.print("finally"),e.space(),i(n.body,e)}),Be.DEFMETHOD("_do_print",function(n,e){n.print(e),n.space(),this.definitions.forEach(function(e,t){t&&n.comma(),e.print(n)});var t=n.parent(),r=t instanceof ae||t instanceof ue,i=r&&t.init===this;i||n.semicolon()}),n(Te,function(n,e){n._do_print(e,"var")}),n($e,function(n,e){n._do_print(e,"const")}),n(Oe,function(n,e){if(n.name.print(e),n.value){e.space(),e.print("="),e.space();var t=e.parent(1),r=t instanceof ae||t instanceof ue;a(n.value,e,r)}}),n(Me,function(n,e){n.expression.print(e),n instanceof Ne&&f(n,e)||e.with_parens(function(){n.args.forEach(function(n,t){t&&e.comma(),n.print(e)})})}),n(Ne,function(n,e){e.print("new"),e.space(),Me.prototype._codegen(n,e)}),Re.DEFMETHOD("_do_print",function(n){this.car.print(n),this.cdr&&(n.comma(),n.should_break()&&(n.newline(),n.indent()),this.cdr.print(n))}),n(Re,function(n,e){n._do_print(e)}),n(He,function(n,e){var t=n.expression;t.print(e),t instanceof dt&&t.getValue()>=0&&(/[xa-f.]/i.test(e.last())||e.print(".")),e.print("."),e.add_mapping(n.end),e.print_name(n.property)}),n(ze,function(n,e){n.expression.print(e),e.print("["),n.property.print(e),e.print("]")}),n(je,function(n,e){var t=n.operator;e.print(t),(/^[a-z]/i.test(t)||/[+-]$/.test(t)&&n.expression instanceof je&&/^[+-]/.test(n.expression.operator))&&e.space(),n.expression.print(e)}),n(Ie,function(n,e){n.expression.print(e),e.print(n.operator)}),n(Ue,function(n,e){n.left.print(e),e.space(),e.print(n.operator),"<"==n.operator&&n.right instanceof je&&"!"==n.right.operator&&n.right.expression instanceof je&&"--"==n.right.expression.operator?e.print(" "):e.space(),n.right.print(e)}),n(Ve,function(n,e){n.condition.print(e),e.space(),e.print("?"),e.space(),n.consequent.print(e),e.space(),e.colon(),n.alternative.print(e)}),n(We,function(n,e){e.with_square(function(){var t=n.elements,r=t.length;r>0&&e.space(),t.forEach(function(n,t){t&&e.comma(),n.print(e),t===r-1&&n instanceof bt&&e.comma()}),r>0&&e.space()})}),n(Ye,function(n,e){n.properties.length>0?e.with_block(function(){n.properties.forEach(function(n,t){t&&(e.print(","),e.newline()),e.indent(),n.print(e)}),e.newline()}):e.print("{}")}),n(Ge,function(n,e){var t=n.key;e.option("quote_keys")?e.print_string(t+""):("number"==typeof t||!e.option("beautify")&&+t+""==t)&&parseFloat(t)>=0?e.print(d(t)):(St(t)?e.option("screw_ie8"):$(t))?e.print_name(t):e.print_string(t),e.colon(),n.value.print(e)}),n(Ke,function(n,e){e.print("set"),e.space(),n.key.print(e),n.value._do_print(e,!0)}),n(Je,function(n,e){e.print("get"),e.space(),n.key.print(e),n.value._do_print(e,!0)}),n(Ze,function(n,e){var t=n.definition();e.print_name(t?t.mangled_name||t.name:n.name)}),n(gt,function(n,e){e.print("void 0")}),n(bt,l),n(yt,function(n,e){e.print("1/0")}),n(vt,function(n,e){e.print("0/0")}),n(ft,function(n,e){e.print("this")}),n(lt,function(n,e){e.print(n.getValue())}),n(pt,function(n,e){e.print_string(n.getValue())}),n(dt,function(n,e){e.print(d(n.getValue()))}),n(ht,function(n,e){var t=n.getValue().toString();e.option("ascii_only")?t=e.to_ascii(t):e.option("unescape_regexps")&&(t=t.split("\\\\").map(function(n){return n.replace(/\\u[0-9a-fA-F]{4}|\\x[0-9a-fA-F]{2}/g,function(n){var e=parseInt(n.substr(2),16);return u(e)?String.fromCharCode(e):n})}).join("\\\\")),e.print(t);var r=e.parent();r instanceof Ue&&/^in/.test(r.operator)&&r.left===n&&e.print(" ")}),_(W,l),_(G,m),_(X,m),_(Ze,m),_(_e,m),_(ne,m),_(ee,l),_(le,m),_(Ee,m),_(De,m),_(Z,m),_(fe,l),_(Ne,m),_(Ce,m),_(ke,m),_(xe,m),_(Be,m),_(lt,m),_(Xe,function(n,e){e.add_mapping(n.start,n.key)})}(),I.prototype=new z,f(I.prototype,{option:function(n){return this.options[n]},warn:function(){this.options.warnings&&W.warn.apply(W,arguments)},before:function(n,e){if(n._squeezed)return n;var t=!1;return n instanceof ce&&(n=n.hoist_declarations(this),t=!0),e(n,this),n=n.optimize(this),t&&n instanceof ce&&(n.drop_unused(this),e(n,this)),n._squeezed=!0,n}}),function(){function n(n,e){n.DEFMETHOD("optimize",function(n){var t=this;if(t._optimized)return t;var r=e(t,n);return r._optimized=!0,r===t?r:r.transform(n)})}function e(n,e,t){return t||(t={}),e&&(t.start||(t.start=e.start),t.end||(t.end=e.end)),new n(t)}function t(n,t,r){if(t instanceof W)return t.transform(n);switch(typeof t){case"string":return e(pt,r,{value:t}).optimize(n);case"number":return e(isNaN(t)?vt:dt,r,{value:t}).optimize(n);case"boolean":return e(t?Et:wt,r).optimize(n);case"undefined":return e(gt,r).optimize(n);default:if(null===t)return e(mt,r).optimize(n);if(t instanceof RegExp)return e(ht,r).optimize(n);throw new Error(d("Can't handle constant of type: {type}",{type:typeof t}))}}function r(n){if(null===n)return[];if(n instanceof Z)return n.body;if(n instanceof Q)return[];if(n instanceof Y)return[n];throw new Error("Can't convert thing to statement array")}function i(n){return null===n?!0:n instanceof Q?!0:n instanceof Z?0==n.body.length:!1}function u(n){return n instanceof Ee?n:(n instanceof ae||n instanceof ue||n instanceof re)&&n.body instanceof Z?n.body:n}function s(n,t){function i(n){function r(n,t){return e(K,n,{body:e(Le,n,{operator:"=",left:e(He,t,{expression:e(st,t,t),property:"$inject"}),right:e(We,n,{elements:n.argnames.map(function(n){return e(pt,n,{value:n.name})})})})})}return n.reduce(function(n,e){n.push(e);var i=e.start,o=i.comments_before;if(o&&o.length>0){var a=o.pop();/@ngInject/.test(a.value)&&(e instanceof he?n.push(r(e,e.name)):e instanceof Be?e.definitions.forEach(function(e){e.value&&e.value instanceof le&&n.push(r(e.value,e.name))}):t.warn("Unknown statement marked with @ngInject [{file}:{line},{col}]",i))}return n},[])}function o(n){var e=[];return n.reduce(function(n,t){return t instanceof Z?(_=!0,n.push.apply(n,o(t.body))):t instanceof Q?_=!0:t instanceof G?e.indexOf(t.value)<0?(n.push(t),e.push(t.value)):_=!0:n.push(t),n},[])}function a(n,t){var i=t.self(),o=i instanceof le,a=[];n:for(var s=n.length;--s>=0;){var c=n[s];switch(!0){case o&&c instanceof ve&&!c.value&&0==a.length:_=!0;continue n;case c instanceof we:if(c.body instanceof ve){if((o&&0==a.length||a[0]instanceof ve&&!a[0].value)&&!c.body.value&&!c.alternative){_=!0;var f=e(K,c.condition,{body:c.condition});a.unshift(f);continue n}if(a[0]instanceof ve&&c.body.value&&a[0].value&&!c.alternative){_=!0,c=c.clone(),c.alternative=a[0],a[0]=c.transform(t);continue n}if((0==a.length||a[0]instanceof ve)&&c.body.value&&!c.alternative&&o){_=!0,c=c.clone(),c.alternative=a[0]||e(ve,c,{value:e(gt,c)}),a[0]=c.transform(t);continue n}if(!c.body.value&&o){_=!0,c=c.clone(),c.condition=c.condition.negate(t),c.body=e(Z,c,{body:r(c.alternative).concat(a)}),c.alternative=null,a=[c.transform(t)];continue n}if(1==a.length&&o&&a[0]instanceof K&&(!c.alternative||c.alternative instanceof K)){_=!0,a.push(e(ve,a[0],{value:e(gt,a[0])}).transform(t)),a=r(c.alternative).concat(a),a.unshift(c);continue n}}var l=m(c.body),p=l instanceof be?t.loopcontrol_target(l.label):null;if(l&&(l instanceof ve&&!l.value&&o||l instanceof Ae&&i===u(p)||l instanceof ye&&p instanceof Z&&i===p)){l.label&&h(l.label.thedef.references,l),_=!0;var d=r(c.body).slice(0,-1);c=c.clone(),c.condition=c.condition.negate(t),c.body=e(Z,c,{body:r(c.alternative).concat(a)}),c.alternative=e(Z,c,{body:d}),a=[c.transform(t)];continue n}var l=m(c.alternative),p=l instanceof be?t.loopcontrol_target(l.label):null;if(l&&(l instanceof ve&&!l.value&&o||l instanceof Ae&&i===u(p)||l instanceof ye&&p instanceof Z&&i===p)){l.label&&h(l.label.thedef.references,l),_=!0,c=c.clone(),c.body=e(Z,c.body,{body:r(c.body).concat(a)}),c.alternative=e(Z,c.alternative,{body:r(c.alternative).slice(0,-1)}),a=[c.transform(t)];continue n}a.unshift(c);break;default:a.unshift(c)}}return a}function s(n,e){var t=!1,r=n.length,i=e.self();return n=n.reduce(function(n,r){if(t)c(e,r,n);else{if(r instanceof be){var o=e.loopcontrol_target(r.label);r instanceof ye&&o instanceof Z&&u(o)===i||r instanceof Ae&&u(o)===i?r.label&&h(r.label.thedef.references,r):n.push(r)}else n.push(r);m(r)&&(t=!0)}return n},[]),_=n.length!=r,n}function f(n,t){function r(){i=Re.from_array(i),i&&o.push(e(K,i,{body:i})),i=[]}if(n.length<2)return n;var i=[],o=[];return n.forEach(function(n){n instanceof K?i.push(n.body):(r(),o.push(n))}),r(),o=l(o,t),_=o.length!=n.length,o}function l(n,t){function r(n){i.pop();var e=o.body;return e instanceof Re?e.add(n):e=Re.cons(e,n),e.transform(t)}var i=[],o=null;return n.forEach(function(n){if(o)if(n instanceof ae){var t={};try{o.body.walk(new E(function(n){if(n instanceof Ue&&"in"==n.operator)throw t})),!n.init||n.init instanceof Be?n.init||(n.init=o.body,i.pop()):n.init=r(n.init)}catch(a){if(a!==t)throw a}}else n instanceof we?n.condition=r(n.condition):n instanceof se?n.expression=r(n.expression):n instanceof me&&n.value?n.value=r(n.value):n instanceof me?n.value=r(e(gt,n)):n instanceof Ee&&(n.expression=r(n.expression));i.push(n),o=n instanceof K?n:null}),i}function p(n){var e=null;return n.reduce(function(n,t){return t instanceof Be&&e&&e.TYPE==t.TYPE?(e.definitions=e.definitions.concat(t.definitions),_=!0):t instanceof ae&&e instanceof Be&&(!t.init||t.init.TYPE==e.TYPE)?(_=!0,n.pop(),t.init?t.init.definitions=e.definitions.concat(t.init.definitions):t.init=e,n.push(t),e=t):(e=t,n.push(t)),n},[])}function d(n){n.forEach(function(n){n instanceof K&&(n.body=function t(n){return n.transform(new z(function(n){if(n instanceof Me&&n.expression instanceof de)return e(je,n,{operator:"!",expression:n});if(n instanceof Me)n.expression=t(n.expression);else if(n instanceof Re)n.car=t(n.car);else if(n instanceof Ve){var r=t(n.condition);if(r!==n.condition){n.condition=r;var i=n.consequent;n.consequent=n.alternative,n.alternative=i}}return n}))}(n.body))})}var _;do _=!1,t.option("angular")&&(n=i(n)),n=o(n),t.option("dead_code")&&(n=s(n,t)),t.option("if_return")&&(n=a(n,t)),t.option("sequences")&&(n=f(n,t)),t.option("join_vars")&&(n=p(n,t));while(_);return t.option("negate_iife")&&d(n,t),n}function c(n,e,t){n.warn("Dropping unreachable code [{file}:{line},{col}]",e.start),e.walk(new E(function(e){return e instanceof Be?(n.warn("Declarations in unreachable code! [{file}:{line},{col}]",e.start),e.remove_initializers(),t.push(e),!0):e instanceof he?(t.push(e),!0):e instanceof ce?!0:void 0}))}function f(n,e){return n.print_to_string().length>e.print_to_string().length?e:n}function m(n){return n&&n.aborts()}function v(n,t){function i(i){i=r(i),n.body instanceof Z?(n.body=n.body.clone(),n.body.body=i.concat(n.body.body.slice(1)),n.body=n.body.transform(t)):n.body=e(Z,n.body,{body:i}).transform(t),v(n,t)}var o=n.body instanceof Z?n.body.body[0]:n.body;o instanceof we&&(o.body instanceof ye&&t.loopcontrol_target(o.body.label)===n?(n.condition=n.condition?e(Ue,n.condition,{left:n.condition,operator:"&&",right:o.condition.negate(t)}):o.condition.negate(t),i(o.alternative)):o.alternative instanceof ye&&t.loopcontrol_target(o.alternative.label)===n&&(n.condition=n.condition?e(Ue,n.condition,{left:n.condition,operator:"&&",right:o.condition}):o.condition,i(o.body)))}function A(n,e){var t=e.option("pure_getters");e.options.pure_getters=!1;var r=n.has_side_effects(e);return e.options.pure_getters=t,r}function w(n,t){return t.option("booleans")&&t.in_boolean_context()?e(Et,n):n}n(W,function(n){return n}),W.DEFMETHOD("equivalent_to",function(n){return this.print_to_string()==n.print_to_string()}),function(n){var e=["!","delete"],t=["in","instanceof","==","!=","===","!==","<","<=",">=",">"];n(W,function(){return!1}),n(je,function(){return o(this.operator,e)}),n(Ue,function(){return o(this.operator,t)||("&&"==this.operator||"||"==this.operator)&&this.left.is_boolean()&&this.right.is_boolean()}),n(Ve,function(){return this.consequent.is_boolean()&&this.alternative.is_boolean()}),n(Le,function(){return"="==this.operator&&this.right.is_boolean()}),n(Re,function(){return this.cdr.is_boolean()}),n(Et,function(){return!0}),n(wt,function(){return!0})}(function(n,e){n.DEFMETHOD("is_boolean",e)}),function(n){n(W,function(){return!1}),n(pt,function(){return!0}),n(je,function(){return"typeof"==this.operator}),n(Ue,function(n){return"+"==this.operator&&(this.left.is_string(n)||this.right.is_string(n))}),n(Le,function(n){return("="==this.operator||"+="==this.operator)&&this.right.is_string(n)}),n(Re,function(n){return this.cdr.is_string(n)}),n(Ve,function(n){return this.consequent.is_string(n)&&this.alternative.is_string(n)}),n(Me,function(n){return n.option("unsafe")&&this.expression instanceof st&&"String"==this.expression.name&&this.expression.undeclared()})}(function(n,e){n.DEFMETHOD("is_string",e)}),function(n){function e(n,e){if(!e)throw new Error("Compressor must be passed");return n._eval(e)}W.DEFMETHOD("evaluate",function(e){if(!e.option("evaluate"))return[this];try{var r=this._eval(e);return[f(t(e,r,this),this),r]}catch(i){if(i!==n)throw i;return[this]}}),n(Y,function(){throw new Error(d("Cannot evaluate a statement [{file}:{line},{col}]",this.start))}),n(de,function(){throw n}),n(W,function(){throw n}),n(lt,function(){return this.getValue()}),n(je,function(t){var r=this.expression;switch(this.operator){case"!":return!e(r,t);case"typeof":if(r instanceof de)return"function";if(r=e(r,t),r instanceof RegExp)throw n;return typeof r;case"void":return void e(r,t);case"~":return~e(r,t);case"-":if(r=e(r,t),0===r)throw n;return-r;case"+":return+e(r,t)}throw n}),n(Ue,function(t){var r=this.left,i=this.right;switch(this.operator){case"&&":return e(r,t)&&e(i,t);case"||":return e(r,t)||e(i,t);case"|":return e(r,t)|e(i,t);case"&":return e(r,t)&e(i,t);case"^":return e(r,t)^e(i,t);case"+":return e(r,t)+e(i,t);case"*":return e(r,t)*e(i,t);case"/":return e(r,t)/e(i,t);case"%":return e(r,t)%e(i,t);case"-":return e(r,t)-e(i,t);case"<<":return e(r,t)<>":return e(r,t)>>e(i,t);case">>>":return e(r,t)>>>e(i,t);case"==":return e(r,t)==e(i,t);case"===":return e(r,t)===e(i,t);case"!=":return e(r,t)!=e(i,t);case"!==":return e(r,t)!==e(i,t);case"<":return e(r,t)":return e(r,t)>e(i,t);case">=":return e(r,t)>=e(i,t);case"in":return e(r,t)in e(i,t);case"instanceof":return e(r,t)instanceof e(i,t)}throw n}),n(Ve,function(n){return e(this.condition,n)?e(this.consequent,n):e(this.alternative,n)}),n(st,function(t){var r=this.definition();if(r&&r.constant&&r.init)return e(r.init,t);throw n}),n(He,function(t){if(t.option("unsafe")&&"length"==this.property){var r=e(this.expression,t);if("string"==typeof r)return r.length}throw n})}(function(n,e){n.DEFMETHOD("_eval",e)}),function(n){function t(n){return e(je,n,{operator:"!",expression:n})}n(W,function(){return t(this)}),n(Y,function(){throw new Error("Cannot negate a statement")}),n(de,function(){return t(this)}),n(je,function(){return"!"==this.operator?this.expression:t(this)}),n(Re,function(n){var e=this.clone();return e.cdr=e.cdr.negate(n),e}),n(Ve,function(n){var e=this.clone();return e.consequent=e.consequent.negate(n),e.alternative=e.alternative.negate(n),f(t(this),e)}),n(Ue,function(n){var e=this.clone(),r=this.operator;if(n.option("unsafe_comps"))switch(r){case"<=":return e.operator=">",e;case"<":return e.operator=">=",e;case">=":return e.operator="<",e;case">":return e.operator="<=",e}switch(r){case"==":return e.operator="!=",e;case"!=":return e.operator="==",e;case"===":return e.operator="!==",e;case"!==":return e.operator="===",e;case"&&":return e.operator="||",e.left=e.left.negate(n),e.right=e.right.negate(n),f(t(this),e);case"||":return e.operator="&&",e.left=e.left.negate(n),e.right=e.right.negate(n),f(t(this),e)}return t(this)})}(function(n,e){n.DEFMETHOD("negate",function(n){return e.call(this,n)})}),function(n){n(W,function(){return!0}),n(Q,function(){return!1}),n(lt,function(){return!1}),n(ft,function(){return!1}),n(Me,function(n){var e=n.option("pure_funcs");return e?e.indexOf(this.expression.print_to_string())<0:!0}),n(J,function(n){for(var e=this.body.length;--e>=0;)if(this.body[e].has_side_effects(n))return!0;return!1}),n(K,function(n){return this.body.has_side_effects(n)}),n(he,function(){return!0}),n(de,function(){return!1}),n(Ue,function(n){return this.left.has_side_effects(n)||this.right.has_side_effects(n)}),n(Le,function(){return!0}),n(Ve,function(n){return this.condition.has_side_effects(n)||this.consequent.has_side_effects(n)||this.alternative.has_side_effects(n)}),n(Pe,function(n){return"delete"==this.operator||"++"==this.operator||"--"==this.operator||this.expression.has_side_effects(n)}),n(st,function(){return!1}),n(Ye,function(n){for(var e=this.properties.length;--e>=0;)if(this.properties[e].has_side_effects(n))return!0;return!1}),n(Xe,function(n){return this.value.has_side_effects(n)}),n(We,function(n){for(var e=this.elements.length;--e>=0;)if(this.elements[e].has_side_effects(n))return!0;return!1}),n(He,function(n){return n.option("pure_getters")?this.expression.has_side_effects(n):!0}),n(ze,function(n){return n.option("pure_getters")?this.expression.has_side_effects(n)||this.property.has_side_effects(n):!0}),n(qe,function(n){return!n.option("pure_getters")}),n(Re,function(n){return this.car.has_side_effects(n)||this.cdr.has_side_effects(n)})}(function(n,e){n.DEFMETHOD("has_side_effects",e)}),function(n){function e(){var n=this.body.length;return n>0&&m(this.body[n-1])}n(Y,function(){return null}),n(_e,function(){return this}),n(Z,e),n(De,e),n(we,function(){return this.alternative&&m(this.body)&&m(this.alternative)})}(function(n,e){n.DEFMETHOD("aborts",e)}),n(G,function(n){return n.scope.has_directive(n.value)!==n.scope?e(Q,n):n}),n(X,function(n,t){return t.option("drop_debugger")?e(Q,n):n}),n(ee,function(n,t){return n.body instanceof ye&&t.loopcontrol_target(n.body.label)===n.body?e(Q,n):0==n.label.references.length?n.body:n}),n(J,function(n,e){return n.body=s(n.body,e),n}),n(Z,function(n,t){switch(n.body=s(n.body,t),n.body.length){case 1:return n.body[0];case 0:return e(Q,n)}return n}),ce.DEFMETHOD("drop_unused",function(n){var t=this;if(n.option("unused")&&!(t instanceof fe)&&!t.uses_eval){var r=[],i=new y,a=this,u=new E(function(e,o){if(e!==t){if(e instanceof he)return i.add(e.name.name,e),!0;if(e instanceof Be&&a===t)return e.definitions.forEach(function(e){e.value&&(i.add(e.name.name,e.value),e.value.has_side_effects(n)&&e.value.walk(u))}),!0;if(e instanceof st)return p(r,e.definition()),!0;if(e instanceof ce){var s=a;return a=e,o(),a=s,!0}}});t.walk(u);for(var s=0;s=0;){var l=s[f];if(!l.unreferenced())break;s.pop(),n.warn("Dropping unused function argument {name} [{file}:{line},{col}]",{name:l.name,file:l.start.file,line:l.start.line,col:l.start.col})}if(i instanceof he&&i!==t)return o(i.name.definition(),r)?i:(n.warn("Dropping unused function {name} [{file}:{line},{col}]",{name:i.name.name,file:i.name.start.file,line:i.name.start.line,col:i.name.start.col}),e(Q,i));if(i instanceof Be&&!(c.parent()instanceof ue)){var p=i.definitions.filter(function(e){if(o(e.name.definition(),r))return!0;var t={name:e.name.name,file:e.name.start.file,line:e.name.start.line,col:e.name.start.col};return e.value&&e.value.has_side_effects(n)?(e._unused_side_effects=!0,n.warn("Side effects in initialization of unused variable {name} [{file}:{line},{col}]",t),!0):(n.warn("Dropping unused variable {name} [{file}:{line},{col}]",t),!1)});p=_(p,function(n,e){return!n.value&&e.value?-1:!e.value&&n.value?1:0});for(var d=[],f=0;f0&&(d.push(h.value),h.value=Re.from_array(d),d=[]),++f)}return d=d.length>0?e(Z,i,{body:[e(K,i,{body:Re.from_array(d)})]}):null,0!=p.length||d?0==p.length?d:(i.definitions=p,d&&(d.body.unshift(i),i=d),i):e(Q,i)}if(i instanceof ae&&(a(i,this),i.init instanceof Z)){var m=i.init.body.slice(0,-1);return i.init=i.init.body.slice(-1)[0].body,m.push(i),u?V.splice(m):e(Z,i,{body:m})}return i instanceof ce&&i!==t?i:void 0});t.transform(c)}}),ce.DEFMETHOD("hoist_declarations",function(n){var t=n.option("hoist_funs"),r=n.option("hoist_vars"),i=this;if(t||r){var o=[],u=[],s=new y,c=0,f=0;i.walk(new E(function(n){return n instanceof ce&&n!==i?!0:n instanceof Te?(++f,!0):void 0})),r=r&&f>1;var l=new z(function(n){if(n!==i){if(n instanceof G)return o.push(n),e(Q,n);if(n instanceof he&&t)return u.push(n),e(Q,n);if(n instanceof Te&&r){n.definitions.forEach(function(n){s.set(n.name.name,n),++c});var a=n.to_assignments(),f=l.parent();return f instanceof ue&&f.init===n?null==a?n.definitions[0].name:a:f instanceof ae&&f.init===n?a:a?e(K,n,{body:a}):e(Q,n)}if(n instanceof ce)return n}});if(i=i.transform(l),c>0){var p=[];if(s.each(function(n,e){i instanceof le&&a(function(e){return e.name==n.name.name},i.argnames)?s.del(e):(n=n.clone(),n.value=null,p.push(n),s.set(e,n))}),p.length>0){for(var d=0;d1){if(r[1])return e(ae,n,{body:n.body});if(n instanceof oe&&t.option("dead_code")){var i=[];return c(t,n.body,i),e(Z,n,{body:i})}}return n}),n(oe,function(n,t){return t.option("loops")?(n=re.prototype.optimize.call(n,t),n instanceof oe&&(v(n,t),n=e(ae,n,n).transform(t)),n):n}),n(ae,function(n,t){var r=n.condition;if(r&&(r=r.evaluate(t),n.condition=r[0]),!t.option("loops"))return n;if(r&&r.length>1&&!r[1]&&t.option("dead_code")){var i=[];return n.init instanceof Y?i.push(n.init):n.init&&i.push(e(K,n.init,{body:n.init})),c(t,n.body,i),e(Z,n,{body:i})}return v(n,t),n}),n(we,function(n,t){if(!t.option("conditionals"))return n;var r=n.condition.evaluate(t);if(n.condition=r[0],r.length>1)if(r[1]){if(t.warn("Condition always true [{file}:{line},{col}]",n.condition.start),t.option("dead_code")){var o=[];return n.alternative&&c(t,n.alternative,o),o.push(n.body),e(Z,n,{body:o}).transform(t)}}else if(t.warn("Condition always false [{file}:{line},{col}]",n.condition.start),t.option("dead_code")){var o=[];return c(t,n.body,o),n.alternative&&o.push(n.alternative),e(Z,n,{body:o}).transform(t)}i(n.alternative)&&(n.alternative=null);var a=n.condition.negate(t),u=f(n.condition,a)===a;if(n.alternative&&u){u=!1,n.condition=a;var s=n.body;n.body=n.alternative||e(Q),n.alternative=s}if(i(n.body)&&i(n.alternative))return e(K,n.condition,{body:n.condition}).transform(t);if(n.body instanceof K&&n.alternative instanceof K)return e(K,n,{body:e(Ve,n,{condition:n.condition,consequent:n.body.body,alternative:n.alternative.body})}).transform(t);if(i(n.alternative)&&n.body instanceof K)return u?e(K,n,{body:e(Ue,n,{operator:"||",left:a,right:n.body.body})}).transform(t):e(K,n,{body:e(Ue,n,{operator:"&&",left:n.condition,right:n.body.body})}).transform(t);if(n.body instanceof Q&&n.alternative&&n.alternative instanceof K)return e(K,n,{body:e(Ue,n,{operator:"||",left:n.condition,right:n.alternative.body})}).transform(t);if(n.body instanceof me&&n.alternative instanceof me&&n.body.TYPE==n.alternative.TYPE)return e(n.body.CTOR,n,{value:e(Ve,n,{condition:n.condition,consequent:n.body.value||e(gt,n.body).optimize(t),alternative:n.alternative.value||e(gt,n.alternative).optimize(t)})}).transform(t);if(n.body instanceof we&&!n.body.alternative&&!n.alternative&&(n.condition=e(Ue,n.condition,{operator:"&&",left:n.condition,right:n.body.condition}).transform(t),n.body=n.body.body),m(n.body)&&n.alternative){var l=n.alternative;return n.alternative=null,e(Z,n,{body:[n,l]}).transform(t)}if(m(n.alternative)){var p=n.body;return n.body=n.alternative,n.condition=u?a:n.condition.negate(t),n.alternative=null,e(Z,n,{body:[n,p]}).transform(t)}return n}),n(Ee,function(n,t){if(0==n.body.length&&t.option("conditionals"))return e(K,n,{body:n.expression}).transform(t);for(;;){var r=n.body[n.body.length-1];if(r){var i=r.body[r.body.length-1];if(i instanceof ye&&u(t.loopcontrol_target(i.label))===n&&r.body.pop(),r instanceof Fe&&0==r.body.length){n.body.pop();continue}}break}var o=n.expression.evaluate(t);n:if(2==o.length)try{if(n.expression=o[0],!t.option("dead_code"))break n;var a=o[1],s=!1,c=!1,f=!1,l=!1,p=!1,d=new z(function(r,i,o){if(r instanceof le||r instanceof K)return r;if(r instanceof Ee&&r===n)return r=r.clone(),i(r,this),p?r:e(Z,r,{body:r.body.reduce(function(n,e){return n.concat(e.body)},[])}).transform(t);if(r instanceof we||r instanceof Ce){var u=s;return s=!c,i(r,this),s=u,r}if(r instanceof ne||r instanceof Ee){var u=c;return c=!0,i(r,this),c=u,r}if(r instanceof ye&&this.loopcontrol_target(r.label)===n)return s?(p=!0,r):c?r:(l=!0,o?V.skip:e(Q,r));if(r instanceof De&&this.parent()===n){if(l)return V.skip;if(r instanceof Se){var d=r.expression.evaluate(t);if(d.length<2)throw n;return d[1]===a||f?(f=!0,m(r)&&(l=!0),i(r,this),r):V.skip}return i(r,this),r}});d.stack=t.stack.slice(),n=n.transform(d)}catch(h){if(h!==n)throw h}return n}),n(Se,function(n,e){return n.body=s(n.body,e),n}),n(Ce,function(n,e){return n.body=s(n.body,e),n}),Be.DEFMETHOD("remove_initializers",function(){this.definitions.forEach(function(n){n.value=null})}),Be.DEFMETHOD("to_assignments",function(){var n=this.definitions.reduce(function(n,t){if(t.value){var r=e(st,t.name,t.name);n.push(e(Le,t,{operator:"=",left:r,right:t.value}))}return n},[]);return 0==n.length?null:Re.from_array(n)}),n(Be,function(n){return 0==n.definitions.length?e(Q,n):n}),n(de,function(n,e){return n=le.prototype.optimize.call(n,e),e.option("unused")&&n.name&&n.name.unreferenced()&&(n.name=null),n}),n(Me,function(n,r){if(r.option("unsafe")){var i=n.expression;if(i instanceof st&&i.undeclared())switch(i.name){case"Array":if(1!=n.args.length)return e(We,n,{elements:n.args}).transform(r);break;case"Object":if(0==n.args.length)return e(Ye,n,{properties:[]});break;case"String":if(0==n.args.length)return e(pt,n,{value:""});if(n.args.length<=1)return e(Ue,n,{left:n.args[0],operator:"+",right:e(pt,n,{value:""})}).transform(r);break;case"Number":if(0==n.args.length)return e(dt,n,{value:0});if(1==n.args.length)return e(je,n,{expression:n.args[0],operator:"+"}).transform(r);case"Boolean":if(0==n.args.length)return e(wt,n);if(1==n.args.length)return e(je,n,{expression:e(je,null,{expression:n.args[0],operator:"!"}),operator:"!"}).transform(r);break;case"Function":if(b(n.args,function(n){return n instanceof pt}))try{var o="(function("+n.args.slice(0,-1).map(function(n){return n.value}).join(",")+"){"+n.args[n.args.length-1].value+"})()",a=H(o); +a.figure_out_scope({screw_ie8:r.option("screw_ie8")});var u=new I(r.options);a=a.transform(u),a.figure_out_scope({screw_ie8:r.option("screw_ie8")}),a.mangle_names();var s;try{a.walk(new E(function(n){if(n instanceof le)throw s=n,a}))}catch(c){if(c!==a)throw c}var l=s.argnames.map(function(t,r){return e(pt,n.args[r],{value:t.print_to_string()})}),o=j();return Z.prototype._codegen.call(s,s,o),o=o.toString().replace(/^\{|\}$/g,""),l.push(e(pt,n.args[n.args.length-1],{value:o})),n.args=l,n}catch(c){if(!(c instanceof M))throw console.log(c),c;r.warn("Error parsing code passed to new Function [{file}:{line},{col}]",n.args[n.args.length-1].start),r.warn(c.toString())}}else{if(i instanceof He&&"toString"==i.property&&0==n.args.length)return e(Ue,n,{left:e(pt,n,{value:""}),operator:"+",right:i.expression}).transform(r);if(i instanceof He&&i.expression instanceof We&&"join"==i.property){var p=0==n.args.length?",":n.args[0].evaluate(r)[1];if(null!=p){var d=i.expression.elements.reduce(function(n,e){if(e=e.evaluate(r),0==n.length||1==e.length)n.push(e);else{var i=n[n.length-1];if(2==i.length){var o=""+i[1]+p+e[1];n[n.length-1]=[t(r,o,i[0]),o]}else n.push(e)}return n},[]);if(0==d.length)return e(pt,n,{value:""});if(1==d.length)return d[0][0];if(""==p){var h;return h=d[0][0]instanceof pt||d[1][0]instanceof pt?d.shift()[0]:e(pt,n,{value:""}),d.reduce(function(n,t){return e(Ue,t[0],{operator:"+",left:n,right:t[0]})},h).transform(r)}var _=n.clone();return _.expression=_.expression.clone(),_.expression.expression=_.expression.expression.clone(),_.expression.expression.elements=d.map(function(n){return n[0]}),f(n,_)}}}}return r.option("side_effects")&&n.expression instanceof de&&0==n.args.length&&!J.prototype.has_side_effects.call(n.expression,r)?e(gt,n).transform(r):r.option("drop_console")&&n.expression instanceof qe&&n.expression.expression instanceof st&&"console"==n.expression.expression.name&&n.expression.expression.undeclared()?e(gt,n).transform(r):n.evaluate(r)[0]}),n(Ne,function(n,t){if(t.option("unsafe")){var r=n.expression;if(r instanceof st&&r.undeclared())switch(r.name){case"Object":case"RegExp":case"Function":case"Error":case"Array":return e(Me,n,n).transform(t)}}return n}),n(Re,function(n,t){if(!t.option("side_effects"))return n;if(!n.car.has_side_effects(t)){var r;if(!(n.cdr instanceof st&&"eval"==n.cdr.name&&n.cdr.undeclared()&&(r=t.parent())instanceof Me&&r.expression===n))return n.cdr}if(t.option("cascade")){if(n.car instanceof Le&&!n.car.left.has_side_effects(t)){if(n.car.left.equivalent_to(n.cdr))return n.car;if(n.cdr instanceof Me&&n.cdr.expression.equivalent_to(n.car.left))return n.cdr.expression=n.car,n.cdr}if(!n.car.has_side_effects(t)&&!n.cdr.has_side_effects(t)&&n.car.equivalent_to(n.cdr))return n.car}return n.cdr instanceof je&&"void"==n.cdr.operator&&!n.cdr.expression.has_side_effects(t)?(n.cdr.operator=n.car,n.cdr):n.cdr instanceof gt?e(je,n,{operator:"void",expression:n.car}):n}),Pe.DEFMETHOD("lift_sequences",function(n){if(n.option("sequences")&&this.expression instanceof Re){var e=this.expression,t=e.to_array();return this.expression=t.pop(),t.push(this),e=Re.from_array(t).transform(n)}return this}),n(Ie,function(n,e){return n.lift_sequences(e)}),n(je,function(n,t){n=n.lift_sequences(t);var r=n.expression;if(t.option("booleans")&&t.in_boolean_context()){switch(n.operator){case"!":if(r instanceof je&&"!"==r.operator)return r.expression;break;case"typeof":return t.warn("Boolean expression always true [{file}:{line},{col}]",n.start),e(Et,n)}r instanceof Ue&&"!"==n.operator&&(n=f(n,r.negate(t)))}return n.evaluate(t)[0]}),Ue.DEFMETHOD("lift_sequences",function(n){if(n.option("sequences")){if(this.left instanceof Re){var e=this.left,t=e.to_array();return this.left=t.pop(),t.push(this),e=Re.from_array(t).transform(n)}if(this.right instanceof Re&&this instanceof Le&&!A(this.left,n)){var e=this.right,t=e.to_array();return this.right=t.pop(),t.push(this),e=Re.from_array(t).transform(n)}}return this});var D=g("== === != !== * & | ^");n(Ue,function(n,t){var r=t.has_directive("use asm")?l:function(e,r){if(r||!n.left.has_side_effects(t)&&!n.right.has_side_effects(t)){e&&(n.operator=e);var i=n.left;n.left=n.right,n.right=i}};if(D(n.operator)&&(n.right instanceof lt&&!(n.left instanceof lt)&&(n.left instanceof Ue&&It[n.left.operator]>=It[n.operator]||r(null,!0)),/^[!=]==?$/.test(n.operator))){if(n.left instanceof st&&n.right instanceof Ve){if(n.right.consequent instanceof st&&n.right.consequent.definition()===n.left.definition()){if(/^==/.test(n.operator))return n.right.condition;if(/^!=/.test(n.operator))return n.right.condition.negate(t)}if(n.right.alternative instanceof st&&n.right.alternative.definition()===n.left.definition()){if(/^==/.test(n.operator))return n.right.condition.negate(t);if(/^!=/.test(n.operator))return n.right.condition}}if(n.right instanceof st&&n.left instanceof Ve){if(n.left.consequent instanceof st&&n.left.consequent.definition()===n.right.definition()){if(/^==/.test(n.operator))return n.left.condition;if(/^!=/.test(n.operator))return n.left.condition.negate(t)}if(n.left.alternative instanceof st&&n.left.alternative.definition()===n.right.definition()){if(/^==/.test(n.operator))return n.left.condition.negate(t);if(/^!=/.test(n.operator))return n.left.condition}}}if(n=n.lift_sequences(t),t.option("comparisons"))switch(n.operator){case"===":case"!==":(n.left.is_string(t)&&n.right.is_string(t)||n.left.is_boolean()&&n.right.is_boolean())&&(n.operator=n.operator.substr(0,2));case"==":case"!=":n.left instanceof pt&&"undefined"==n.left.value&&n.right instanceof je&&"typeof"==n.right.operator&&t.option("unsafe")&&(n.right.expression instanceof st&&n.right.expression.undeclared()||(n.right=n.right.expression,n.left=e(gt,n.left).optimize(t),2==n.operator.length&&(n.operator+="=")))}if(t.option("booleans")&&t.in_boolean_context())switch(n.operator){case"&&":var i=n.left.evaluate(t),o=n.right.evaluate(t);if(i.length>1&&!i[1]||o.length>1&&!o[1])return t.warn("Boolean && always false [{file}:{line},{col}]",n.start),e(wt,n);if(i.length>1&&i[1])return o[0];if(o.length>1&&o[1])return i[0];break;case"||":var i=n.left.evaluate(t),o=n.right.evaluate(t);if(i.length>1&&i[1]||o.length>1&&o[1])return t.warn("Boolean || always true [{file}:{line},{col}]",n.start),e(Et,n);if(i.length>1&&!i[1])return o[0];if(o.length>1&&!o[1])return i[0];break;case"+":var i=n.left.evaluate(t),o=n.right.evaluate(t);if(i.length>1&&i[0]instanceof pt&&i[1]||o.length>1&&o[0]instanceof pt&&o[1])return t.warn("+ in boolean context always true [{file}:{line},{col}]",n.start),e(Et,n)}if(t.option("comparisons")){if(!(t.parent()instanceof Ue)||t.parent()instanceof Le){var a=e(je,n,{operator:"!",expression:n.negate(t)});n=f(n,a)}switch(n.operator){case"<":r(">");break;case"<=":r(">=")}}return"+"==n.operator&&n.right instanceof pt&&""===n.right.getValue()&&n.left instanceof Ue&&"+"==n.left.operator&&n.left.is_string(t)?n.left:(t.option("evaluate")&&"+"==n.operator&&(n.left instanceof lt&&n.right instanceof Ue&&"+"==n.right.operator&&n.right.left instanceof lt&&n.right.is_string(t)&&(n=e(Ue,n,{operator:"+",left:e(pt,null,{value:""+n.left.getValue()+n.right.left.getValue(),start:n.left.start,end:n.right.left.end}),right:n.right.right})),n.right instanceof lt&&n.left instanceof Ue&&"+"==n.left.operator&&n.left.right instanceof lt&&n.left.is_string(t)&&(n=e(Ue,n,{operator:"+",left:n.left.left,right:e(pt,null,{value:""+n.left.right.getValue()+n.right.getValue(),start:n.left.right.start,end:n.right.end})})),n.left instanceof Ue&&"+"==n.left.operator&&n.left.is_string(t)&&n.left.right instanceof lt&&n.right instanceof Ue&&"+"==n.right.operator&&n.right.left instanceof lt&&n.right.is_string(t)&&(n=e(Ue,n,{operator:"+",left:e(Ue,n.left,{operator:"+",left:n.left.left,right:e(pt,null,{value:""+n.left.right.getValue()+n.right.left.getValue(),start:n.left.right.start,end:n.right.left.end})}),right:n.right.right}))),n.right instanceof Ue&&n.right.operator==n.operator&&("*"==n.operator||"&&"==n.operator||"||"==n.operator)?(n.left=e(Ue,n.left,{operator:n.operator,left:n.left,right:n.right.left}),n.right=n.right.right,n.transform(t)):n.evaluate(t)[0])}),n(st,function(n,r){if(n.undeclared()){var i=r.option("global_defs");if(i&&i.hasOwnProperty(n.name))return t(r,i[n.name],n);switch(n.name){case"undefined":return e(gt,n);case"NaN":return e(vt,n);case"Infinity":return e(yt,n)}}return n}),n(gt,function(n,t){if(t.option("unsafe")){var r=t.find_parent(ce),i=r.find_variable("undefined");if(i){var o=e(st,n,{name:"undefined",scope:r,thedef:i});return o.reference(),o}}return n});var F=["+","-","/","*","%",">>","<<",">>>","|","^","&"];n(Le,function(n,e){return n=n.lift_sequences(e),"="==n.operator&&n.left instanceof st&&n.right instanceof Ue&&n.right.left instanceof st&&n.right.left.name==n.left.name&&o(n.right.operator,F)&&(n.operator=n.right.operator+"=",n.right=n.right.right),n}),n(Ve,function(n,t){if(!t.option("conditionals"))return n;if(n.condition instanceof Re){var r=n.condition.car;return n.condition=n.condition.cdr,Re.cons(r,n)}var i=n.condition.evaluate(t);if(i.length>1)return i[1]?(t.warn("Condition always true [{file}:{line},{col}]",n.start),n.consequent):(t.warn("Condition always false [{file}:{line},{col}]",n.start),n.alternative);var o=i[0].negate(t);f(i[0],o)===o&&(n=e(Ve,n,{condition:o,consequent:n.alternative,alternative:n.consequent}));var a=n.consequent,u=n.alternative;if(a instanceof Le&&u instanceof Le&&a.operator==u.operator&&a.left.equivalent_to(u.left))return e(Le,n,{operator:a.operator,left:a.left,right:e(Ve,n,{condition:n.condition,consequent:a.right,alternative:u.right})});if(a instanceof Me&&u.TYPE===a.TYPE&&a.args.length==u.args.length&&a.expression.equivalent_to(u.expression)){if(0==a.args.length)return e(Re,n,{car:n.condition,cdr:a});if(1==a.args.length)return a.args[0]=e(Ve,n,{condition:n.condition,consequent:a.args[0],alternative:u.args[0]}),a}return a instanceof Ve&&a.alternative.equivalent_to(u)?e(Ve,n,{condition:e(Ue,n,{left:n.condition,operator:"&&",right:a.condition}),consequent:a.consequent,alternative:u}):n}),n(At,function(n,t){if(t.option("booleans")){var r=t.parent();return r instanceof Ue&&("=="==r.operator||"!="==r.operator)?(t.warn("Non-strict equality against boolean: {operator} {value} [{file}:{line},{col}]",{operator:r.operator,value:n.value,file:r.start.file,line:r.start.line,col:r.start.col}),e(dt,n,{value:+n.value})):e(je,n,{operator:"!",expression:e(dt,n,{value:1-n.value})})}return n}),n(ze,function(n,t){var r=n.property;if(r instanceof pt&&t.option("properties")){if(r=r.getValue(),St(r)?t.option("screw_ie8"):$(r))return e(He,n,{expression:n.expression,property:r}).optimize(t);var i=parseFloat(r);isNaN(i)||i.toString()!=r||(n.property=e(dt,n.property,{value:i}))}return n}),n(He,function(n,e){return n.evaluate(e)[0]}),n(We,w),n(Ye,w),n(ht,w)}(),function(){function n(n){var r="prefix"in n?n.prefix:"UnaryExpression"==n.type?!0:!1;return new(r?je:Ie)({start:e(n),end:t(n),operator:n.operator,expression:i(n.argument)})}function e(n){return new L({file:n.loc&&n.loc.source,line:n.loc&&n.loc.start.line,col:n.loc&&n.loc.start.column,pos:n.start,endpos:n.start})}function t(n){return new L({file:n.loc&&n.loc.source,line:n.loc&&n.loc.end.line,col:n.loc&&n.loc.end.column,pos:n.end,endpos:n.end})}function r(n,r,a){var u="function From_Moz_"+n+"(M){\n";return u+="return new mytype({\nstart: my_start_token(M),\nend: my_end_token(M)",a&&a.split(/\s*,\s*/).forEach(function(n){var e=/([a-z0-9$_]+)(=|@|>|%)([a-z0-9$_]+)/i.exec(n);if(!e)throw new Error("Can't understand property map: "+n);var t="M."+e[1],r=e[2],i=e[3];if(u+=",\n"+i+": ","@"==r)u+=t+".map(from_moz)";else if(">"==r)u+="from_moz("+t+")";else if("="==r)u+=t;else{if("%"!=r)throw new Error("Can't understand operator in propmap: "+n);u+="from_moz("+t+").body"}}),u+="\n})}",u=new Function("mytype","my_start_token","my_end_token","from_moz","return("+u+")")(r,e,t,i),o[n]=u}function i(n){a.push(n);var e=null!=n?o[n.type](n):null;return a.pop(),e}var o={TryStatement:function(n){return new Ce({start:e(n),end:t(n),body:i(n.block).body,bcatch:i(n.handlers?n.handlers[0]:n.handler),bfinally:n.finalizer?new xe(i(n.finalizer)):null})},CatchClause:function(n){return new ke({start:e(n),end:t(n),argname:i(n.param),body:i(n.body).body})},ObjectExpression:function(n){return new Ye({start:e(n),end:t(n),properties:n.properties.map(function(n){var r=n.key,o="Identifier"==r.type?r.name:r.value,a={start:e(r),end:t(n.value),key:o,value:i(n.value)};switch(n.kind){case"init":return new Ge(a);case"set":return a.value.name=i(r),new Ke(a);case"get":return a.value.name=i(r),new Je(a)}})})},SequenceExpression:function(n){return Re.from_array(n.expressions.map(i))},MemberExpression:function(n){return new(n.computed?ze:He)({start:e(n),end:t(n),property:n.computed?i(n.property):n.property.name,expression:i(n.object)})},SwitchCase:function(n){return new(n.test?Se:Fe)({start:e(n),end:t(n),expression:i(n.test),body:n.consequent.map(i)})},Literal:function(n){var r=n.value,i={start:e(n),end:t(n)};if(null===r)return new mt(i);switch(typeof r){case"string":return i.value=r,new pt(i);case"number":return i.value=r,new dt(i);case"boolean":return new(r?Et:wt)(i);default:return i.value=r,new ht(i)}},UnaryExpression:n,UpdateExpression:n,Identifier:function(n){var r=a[a.length-2];return new("this"==n.name?ft:"LabeledStatement"==r.type?ut:"VariableDeclarator"==r.type&&r.id===n?"const"==r.kind?tt:et:"FunctionExpression"==r.type?r.id===n?ot:rt:"FunctionDeclaration"==r.type?r.id===n?it:rt:"CatchClause"==r.type?at:"BreakStatement"==r.type||"ContinueStatement"==r.type?ct:st)({start:e(n),end:t(n),name:n.name})}};r("Node",W),r("Program",fe,"body@body"),r("Function",de,"id>name, params@argnames, body%body"),r("EmptyStatement",Q),r("BlockStatement",Z,"body@body"),r("ExpressionStatement",K,"expression>body"),r("IfStatement",we,"test>condition, consequent>body, alternate>alternative"),r("LabeledStatement",ee,"label>label, body>body"),r("BreakStatement",ye,"label>label"),r("ContinueStatement",Ae,"label>label"),r("WithStatement",se,"object>expression, body>body"),r("SwitchStatement",Ee,"discriminant>expression, cases@body"),r("ReturnStatement",ve,"argument>value"),r("ThrowStatement",ge,"argument>value"),r("WhileStatement",oe,"test>condition, body>body"),r("DoWhileStatement",ie,"test>condition, body>body"),r("ForStatement",ae,"init>init, test>condition, update>step, body>body"),r("ForInStatement",ue,"left>init, right>object, body>body"),r("DebuggerStatement",X),r("FunctionDeclaration",he,"id>name, params@argnames, body%body"),r("VariableDeclaration",Te,"declarations@definitions"),r("VariableDeclarator",Oe,"id>name, init>value"),r("ThisExpression",ft),r("ArrayExpression",We,"elements@elements"),r("FunctionExpression",de,"id>name, params@argnames, body%body"),r("BinaryExpression",Ue,"operator=operator, left>left, right>right"),r("AssignmentExpression",Le,"operator=operator, left>left, right>right"),r("LogicalExpression",Ue,"operator=operator, left>left, right>right"),r("ConditionalExpression",Ve,"test>condition, consequent>consequent, alternate>alternative"),r("NewExpression",Ne,"callee>expression, arguments@args"),r("CallExpression",Me,"callee>expression, arguments@args");var a=null;W.from_mozilla_ast=function(n){var e=a;a=[];var t=i(n);return a=e,t}}(),n.array_to_hash=t,n.slice=r,n.characters=i,n.member=o,n.find_if=a,n.repeat_string=u,n.DefaultsError=s,n.defaults=c,n.merge=f,n.noop=l,n.MAP=V,n.push_uniq=p,n.string_template=d,n.remove=h,n.mergeSort=_,n.set_difference=m,n.set_intersection=v,n.makePredicate=g,n.all=b,n.Dictionary=y,n.DEFNODE=A,n.AST_Token=L,n.AST_Node=W,n.AST_Statement=Y,n.AST_Debugger=X,n.AST_Directive=G,n.AST_SimpleStatement=K,n.walk_body=w,n.AST_Block=J,n.AST_BlockStatement=Z,n.AST_EmptyStatement=Q,n.AST_StatementWithBody=ne,n.AST_LabeledStatement=ee,n.AST_IterationStatement=te,n.AST_DWLoop=re,n.AST_Do=ie,n.AST_While=oe,n.AST_For=ae,n.AST_ForIn=ue,n.AST_With=se,n.AST_Scope=ce,n.AST_Toplevel=fe,n.AST_Lambda=le,n.AST_Accessor=pe,n.AST_Function=de,n.AST_Defun=he,n.AST_Jump=_e,n.AST_Exit=me,n.AST_Return=ve,n.AST_Throw=ge,n.AST_LoopControl=be,n.AST_Break=ye,n.AST_Continue=Ae,n.AST_If=we,n.AST_Switch=Ee,n.AST_SwitchBranch=De,n.AST_Default=Fe,n.AST_Case=Se,n.AST_Try=Ce,n.AST_Catch=ke,n.AST_Finally=xe,n.AST_Definitions=Be,n.AST_Var=Te,n.AST_Const=$e,n.AST_VarDef=Oe,n.AST_Call=Me,n.AST_New=Ne,n.AST_Seq=Re,n.AST_PropAccess=qe,n.AST_Dot=He,n.AST_Sub=ze,n.AST_Unary=Pe,n.AST_UnaryPrefix=je,n.AST_UnaryPostfix=Ie,n.AST_Binary=Ue,n.AST_Conditional=Ve,n.AST_Assign=Le,n.AST_Array=We,n.AST_Object=Ye,n.AST_ObjectProperty=Xe,n.AST_ObjectKeyVal=Ge,n.AST_ObjectSetter=Ke,n.AST_ObjectGetter=Je,n.AST_Symbol=Ze,n.AST_SymbolAccessor=Qe,n.AST_SymbolDeclaration=nt,n.AST_SymbolVar=et,n.AST_SymbolConst=tt,n.AST_SymbolFunarg=rt,n.AST_SymbolDefun=it,n.AST_SymbolLambda=ot,n.AST_SymbolCatch=at,n.AST_Label=ut,n.AST_SymbolRef=st,n.AST_LabelRef=ct,n.AST_This=ft,n.AST_Constant=lt,n.AST_String=pt,n.AST_Number=dt,n.AST_RegExp=ht,n.AST_Atom=_t,n.AST_Null=mt,n.AST_NaN=vt,n.AST_Undefined=gt,n.AST_Hole=bt,n.AST_Infinity=yt,n.AST_Boolean=At,n.AST_False=wt,n.AST_True=Et,n.TreeWalker=E,n.KEYWORDS=Dt,n.KEYWORDS_ATOM=Ft,n.RESERVED_WORDS=St,n.KEYWORDS_BEFORE_EXPRESSION=Ct,n.OPERATOR_CHARS=kt,n.RE_HEX_NUMBER=xt,n.RE_OCT_NUMBER=Bt,n.RE_DEC_NUMBER=Tt,n.OPERATORS=$t,n.WHITESPACE_CHARS=Ot,n.PUNC_BEFORE_EXPRESSION=Mt,n.PUNC_CHARS=Nt,n.REGEXP_MODIFIERS=Rt,n.UNICODE=qt,n.is_letter=D,n.is_digit=F,n.is_alphanumeric_char=S,n.is_unicode_combining_mark=C,n.is_unicode_connector_punctuation=k,n.is_identifier=x,n.is_identifier_start=B,n.is_identifier_char=T,n.is_identifier_string=$,n.parse_js_number=O,n.JS_Parse_Error=M,n.js_error=N,n.is_token=R,n.EX_EOF=Ht,n.tokenizer=q,n.UNARY_PREFIX=zt,n.UNARY_POSTFIX=Pt,n.ASSIGNMENT=jt,n.PRECEDENCE=It,n.STATEMENTS_WITH_LABELS=Ut,n.ATOMIC_START_TOKEN=Vt,n.parse=H,n.TreeTransformer=z,n.SymbolDef=P,n.base54=Lt,n.OutputStream=j,n.Compressor=I,n.SourceMap=U}({},function(){return this}()); \ No newline at end of file diff --git a/misc/demo/assets/ui-select2.js b/misc/demo/assets/ui-select2.js deleted file mode 100644 index 977c0f1d91..0000000000 --- a/misc/demo/assets/ui-select2.js +++ /dev/null @@ -1,117 +0,0 @@ -angular.module('ui.config', []).value('ui.config', {}); -angular.module('ui.directives', ['ui.config']); - -/** - * Enhanced Select2 Dropmenus - * - * @AJAX Mode - When in this mode, your value will be an object (or array of objects) of the data used by Select2 - * This change is so that you do not have to do an additional query yourself on top of Select2's own query - * @params [options] {object} The configuration options passed to $.fn.select2(). Refer to the documentation - */ -angular.module('ui.directives').directive('uiSelect2', ['ui.config', '$http', function (uiConfig, $http) { - var options = {}; - if (uiConfig.select2) { - angular.extend(options, uiConfig.select2); - } - return { - require: '?ngModel', - compile: function (tElm, tAttrs) { - var watch, - repeatOption, - repeatAttr, - isSelect = tElm.is('select'), - isMultiple = (tAttrs.multiple !== undefined); - - // Enable watching of the options dataset if in use - if (tElm.is('select')) { - repeatOption = tElm.find('option[ng-repeat], option[data-ng-repeat]'); - - if (repeatOption.length) { - repeatAttr = repeatOption.attr('ng-repeat') || repeatOption.attr('data-ng-repeat'); - watch = jQuery.trim(repeatAttr.split('|')[0]).split(' ').pop(); - } - } - - return function (scope, elm, attrs, controller) { - // instance-specific options - var opts = angular.extend({}, options, scope.$eval(attrs.uiSelect2)); - - if (isSelect) { - // Use instead - delete opts.multiple; - delete opts.initSelection; - } else if (isMultiple) { - opts.multiple = true; - } - - if (controller) { - // Watch the model for programmatic changes - controller.$render = function () { - if (isSelect) { - elm.select2('val', controller.$modelValue); - } else { - if (isMultiple && !controller.$modelValue) { - elm.select2('data', []); - } else if (angular.isObject(controller.$modelValue)) { - elm.select2('data', controller.$modelValue); - } else { - elm.select2('val', controller.$modelValue); - } - } - }; - - - // Watch the options dataset for changes - if (watch) { - scope.$watch(watch, function (newVal, oldVal, scope) { - if (!newVal) return; - // Delayed so that the options have time to be rendered - setTimeout(function () { - elm.select2('val', controller.$viewValue); - // Refresh angular to remove the superfluous option - elm.trigger('change'); - }); - }); - } - - if (!isSelect) { - // Set the view and model value and update the angular template manually for the ajax/multiple select2. - elm.bind("change", function () { - scope.$apply(function () { - controller.$setViewValue(elm.select2('data')); - }); - }); - - if (opts.initSelection) { - var initSelection = opts.initSelection; - opts.initSelection = function (element, callback) { - initSelection(element, function (value) { - controller.$setViewValue(value); - callback(value); - }); - }; - } - } - } - - attrs.$observe('disabled', function (value) { - elm.select2(value && 'disable' || 'enable'); - }); - - if (attrs.ngMultiple) { - scope.$watch(attrs.ngMultiple, function(newVal) { - elm.select2(opts); - }); - } - - // Set initial value since Angular doesn't - elm.val(scope.$eval(attrs.ngModel)); - - // Initialize the plugin late so that the injected DOM does not disrupt the template compiler - setTimeout(function () { - elm.select2(opts); - }); - }; - } - }; -}]); diff --git a/misc/demo/index.html b/misc/demo/index.html index 7bdabb1ffe..8f6baa7ac2 100644 --- a/misc/demo/index.html +++ b/misc/demo/index.html @@ -1,209 +1,272 @@ - + - Angular directives for Twitter's Bootstrap - + + + + Angular directives for Bootstrap + - - - - - - + + + + + + + + - + + - - - - + - - - UI Bootstrap - - - - Related Projects: - - AngularUI - Grid - AngularJS.tmbundle - Router New! - - - - - - - Directives - - - <% demoModules.forEach(function(module) { %> - <%= module.displayName %> - <% }); %> - - - Getting started - - + + + Toggle navigation + + + + + UI Bootstrap + + + + + UI Bootstrap + + + + Directives + + + <% demoModules.forEach(function(module) { %><%= module.displayName %><% }); %> + + + Getting started + + + Previous docs + + + + {{version.version}} + + + + + + + + Getting started + Directives + + - - + - - + UI Bootstrap - Bootstrap components written in pure AngularJS by the AngularUI Team + Bootstrap components written in pure AngularJS by the AngularUI Team - - Code on Github - - Download (<%= pkg.version%>) - - Create a Build + + Code on Github + + Download (<%= pkg.version%>) + + Create a Build - - - - - - - - - - - Tweet - - - - - + + + + + + + + + + Tweet + + + + + - - - - - + + + + - + + + + + Directives + <% demoModules.forEach(function(module) { %> + <%= module.displayName %> + <% }); %> + + - - - Getting started - - - + + + Getting started + + Angular 2 + + For Angular 2 support, check out + + ng-bootstrap + , created by the UI Bootstrap team. + Dependencies This repository contains a set of native AngularJS directives based on - Twitter Bootstrap's markup and CSS. As a result no dependency on jQuery or Bootstrap's + Bootstrap's markup and CSS. As a result no dependency on jQuery or Bootstrap's JavaScript is required. The only required dependencies are: - AngularJS (minimal version 1.0.4 or 1.1.2) - Bootstrap CSS + AngularJS (requires AngularJS 1.4.x or higher, tested with <%= ngversion %>). + 0.14.3 is the last version of this library that supports AngularJS 1.3.x and + 0.12.0 is the last version that supports AngularJS 1.2.x. + Angular-animate (the version should match with your angular's, tested with <%= ngversion %>) if you plan in using animations, you need to load angular-animate as well. + Angular-touch (the version should match with your angular's, tested with <%= ngversion %>) if you plan in using swipe actions, you need to load angular-touch as well. + Bootstrap CSS (tested with version <%= bsversion %>). + This version of the library (<%= pkg.version%>) works only with Bootstrap CSS in version 3.x. + 0.8.0 is the last version of this library that supports Bootstrap CSS in version 2.3.x. + Files to download Build files for all directives are distributed in several flavours: minified for production usage, un-minified for development, with or without templates. All the options are described and can be - downloaded from here. + downloaded from here. It should be noted that the -tpls files contain the templates bundled in JavaScript, while the regular version does not contain the bundled templates. For more information, check out the FAQ here and the README here. Alternativelly, if you are only interested in a subset of directives, you can create your own build. - Whichever method you choose the good news that the overall size of a download is very small: - <20kB for all directives (~5kB with gzip compression!) + Whichever method you choose the good news that the overall size of a download is fairly small: + 122K minified for all directives with templates and 98K without (~31kB with gzip + compression, with templates, and 28K gzipped without) Installation As soon as you've got all the files downloaded and included in your page you just need to declare a dependency on the ui.bootstrap module: - angular.module('myModule', ['ui.bootstrap']); + angular.module('myModule', ['ui.bootstrap']); + If you are using UI Bootstrap in the CSP mode, e.g. in an extension, make sure you link to the ui-bootstrap-csp.css in your HTML manually. You can fork one of the plunkers from this page to see a working example of what is described here. - - - + Migration to prefixes + Since version 0.14.0 we started to prefix all our components. If you are upgrading from ui-bootstrap 0.13.4 or earlier, + check our migration guide. + CSS + Original Bootstrap's CSS depends on empty href attributes to style cursors for several components (pagination, tabs etc.). + But in AngularJS adding empty href attributes to link tags will cause unwanted route changes. This is why we need to remove empty href attributes from directive templates and as a result styling is not applied correctly. The remedy is simple, just add the following styling to your application: .nav, .pagination, .carousel, .panel-title a { cursor: pointer; } + + FAQ + Please check our FAQ section for common problems / solutions. + Reading the documentation + + Each of the components provided in ui-bootstrap have documentation and interactive Plunker examples. + + + For the directives, we list the different attributes with their default values. In addition to this, some settings have a badge on it: - <% demoModules.forEach(function(module) { %> - - - <%= module.displayName %> - (ui.bootstrap.<%= module.name %>) - - - - - <%= module.docs.html %> - - - <%= module.docs.md %> - - - - - - - Edit in plunker - - - - - <%- module.docs.html %> + + - This setting has an angular $watch listener applied to it. + B - This setting is a boolean. It doesn't need a parameter. + C - This setting can be configured globally in a constant service*. + $ - This setting expects an angular expression instead of a literal string. If the expression support a boolean / integer, you can pass it directly. + readonly - This setting is readonly. + + + + For the services (you will recognize them with the $ prefix), we list all the possible parameters you can pass to them and their default values if any. + + + * Some directives have a config service that follows the next pattern: uibDirectiveConfig. The service's settings use camel case. The services can be configured in a .config function for example. + + + <% demoModules.forEach(function(module) { %> + + + <%= module.displayName %> + (ui.bootstrap.<%= module.name %>) + + + + + <%= module.docs.html %> + + + <%= module.docs.md %> + - - - - <%- module.docs.js %> + + + + + Edit in plunker + + + + + <%- module.docs.html %> + + + + + <%- module.docs.js %> + + + + - - + + + <% }); %> - - - <% }); %> - - + + @@ -211,28 +274,89 @@ <%= module.displayName %> - - Create a Custom Build - - - {{buildErrorText}} + + + + diff --git a/misc/raw-files-generator.js b/misc/raw-files-generator.js new file mode 100644 index 0000000000..4e563e68ba --- /dev/null +++ b/misc/raw-files-generator.js @@ -0,0 +1,46 @@ +/*! + * Forked from: + * Bootstrap Grunt task for generating raw-files.min.js for the Customizer + * http://getbootstrap.com + * Copyright 2014 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +/* jshint node: true */ + +'use strict'; +var fs = require('fs'); + +function getFiles(filePaths) { + var files = {}; + filePaths + .forEach(function (path) { + files[path] = fs.readFileSync(path, 'utf8'); + }); + return files; +} + +module.exports = function generateRawFilesJs(grunt, jsFilename, files, banner, cssBanner) { + if (!banner) { + banner = ''; + } + + if (!cssBanner) { + cssBanner = ''; + } + + var filesJsObject = { + banner: banner, + cssBanner: cssBanner, + files: getFiles(files), + }; + + var filesJsContent = JSON.stringify(filesJsObject); + try { + fs.writeFileSync(jsFilename, filesJsContent); + } + catch (err) { + grunt.fail.warn(err); + } + grunt.log.writeln('File ' + jsFilename.cyan + ' created.'); +}; diff --git a/misc/test-lib/angular-mocks.js b/misc/test-lib/angular-mocks.js deleted file mode 100644 index b6ecc79d13..0000000000 --- a/misc/test-lib/angular-mocks.js +++ /dev/null @@ -1,1764 +0,0 @@ -/** - * @license AngularJS v1.0.5 - * (c) 2010-2012 Google, Inc. http://angularjs.org - * License: MIT - * - * TODO(vojta): wrap whole file into closure during build - */ - -/** - * @ngdoc overview - * @name angular.mock - * @description - * - * Namespace from 'angular-mocks.js' which contains testing related code. - */ -angular.mock = {}; - -/** - * ! This is a private undocumented service ! - * - * @name ngMock.$browser - * - * @description - * This service is a mock implementation of {@link ng.$browser}. It provides fake - * implementation for commonly used browser apis that are hard to test, e.g. setTimeout, xhr, - * cookies, etc... - * - * The api of this service is the same as that of the real {@link ng.$browser $browser}, except - * that there are several helper methods available which can be used in tests. - */ -angular.mock.$BrowserProvider = function() { - this.$get = function(){ - return new angular.mock.$Browser(); - }; -}; - -angular.mock.$Browser = function() { - var self = this; - - this.isMock = true; - self.$$url = "http://server/"; - self.$$lastUrl = self.$$url; // used by url polling fn - self.pollFns = []; - - // TODO(vojta): remove this temporary api - self.$$completeOutstandingRequest = angular.noop; - self.$$incOutstandingRequestCount = angular.noop; - - - // register url polling fn - - self.onUrlChange = function(listener) { - self.pollFns.push( - function() { - if (self.$$lastUrl != self.$$url) { - self.$$lastUrl = self.$$url; - listener(self.$$url); - } - } - ); - - return listener; - }; - - self.cookieHash = {}; - self.lastCookieHash = {}; - self.deferredFns = []; - self.deferredNextId = 0; - - self.defer = function(fn, delay) { - delay = delay || 0; - self.deferredFns.push({time:(self.defer.now + delay), fn:fn, id: self.deferredNextId}); - self.deferredFns.sort(function(a,b){ return a.time - b.time;}); - return self.deferredNextId++; - }; - - - self.defer.now = 0; - - - self.defer.cancel = function(deferId) { - var fnIndex; - - angular.forEach(self.deferredFns, function(fn, index) { - if (fn.id === deferId) fnIndex = index; - }); - - if (fnIndex !== undefined) { - self.deferredFns.splice(fnIndex, 1); - return true; - } - - return false; - }; - - - /** - * @name ngMock.$browser#defer.flush - * @methodOf ngMock.$browser - * - * @description - * Flushes all pending requests and executes the defer callbacks. - * - * @param {number=} number of milliseconds to flush. See {@link #defer.now} - */ - self.defer.flush = function(delay) { - if (angular.isDefined(delay)) { - self.defer.now += delay; - } else { - if (self.deferredFns.length) { - self.defer.now = self.deferredFns[self.deferredFns.length-1].time; - } else { - throw Error('No deferred tasks to be flushed'); - } - } - - while (self.deferredFns.length && self.deferredFns[0].time <= self.defer.now) { - self.deferredFns.shift().fn(); - } - }; - /** - * @name ngMock.$browser#defer.now - * @propertyOf ngMock.$browser - * - * @description - * Current milliseconds mock time. - */ - - self.$$baseHref = ''; - self.baseHref = function() { - return this.$$baseHref; - }; -}; -angular.mock.$Browser.prototype = { - -/** - * @name ngMock.$browser#poll - * @methodOf ngMock.$browser - * - * @description - * run all fns in pollFns - */ - poll: function poll() { - angular.forEach(this.pollFns, function(pollFn){ - pollFn(); - }); - }, - - addPollFn: function(pollFn) { - this.pollFns.push(pollFn); - return pollFn; - }, - - url: function(url, replace) { - if (url) { - this.$$url = url; - return this; - } - - return this.$$url; - }, - - cookies: function(name, value) { - if (name) { - if (value == undefined) { - delete this.cookieHash[name]; - } else { - if (angular.isString(value) && //strings only - value.length <= 4096) { //strict cookie storage limits - this.cookieHash[name] = value; - } - } - } else { - if (!angular.equals(this.cookieHash, this.lastCookieHash)) { - this.lastCookieHash = angular.copy(this.cookieHash); - this.cookieHash = angular.copy(this.cookieHash); - } - return this.cookieHash; - } - }, - - notifyWhenNoOutstandingRequests: function(fn) { - fn(); - } -}; - - -/** - * @ngdoc object - * @name ngMock.$exceptionHandlerProvider - * - * @description - * Configures the mock implementation of {@link ng.$exceptionHandler} to rethrow or to log errors passed - * into the `$exceptionHandler`. - */ - -/** - * @ngdoc object - * @name ngMock.$exceptionHandler - * - * @description - * Mock implementation of {@link ng.$exceptionHandler} that rethrows or logs errors passed - * into it. See {@link ngMock.$exceptionHandlerProvider $exceptionHandlerProvider} for configuration - * information. - * - * - * - * describe('$exceptionHandlerProvider', function() { - * - * it('should capture log messages and exceptions', function() { - * - * module(function($exceptionHandlerProvider) { - * $exceptionHandlerProvider.mode('log'); - * }); - * - * inject(function($log, $exceptionHandler, $timeout) { - * $timeout(function() { $log.log(1); }); - * $timeout(function() { $log.log(2); throw 'banana peel'; }); - * $timeout(function() { $log.log(3); }); - * expect($exceptionHandler.errors).toEqual([]); - * expect($log.assertEmpty()); - * $timeout.flush(); - * expect($exceptionHandler.errors).toEqual(['banana peel']); - * expect($log.log.logs).toEqual([[1], [2], [3]]); - * }); - * }); - * }); - * - */ - -angular.mock.$ExceptionHandlerProvider = function() { - var handler; - - /** - * @ngdoc method - * @name ngMock.$exceptionHandlerProvider#mode - * @methodOf ngMock.$exceptionHandlerProvider - * - * @description - * Sets the logging mode. - * - * @param {string} mode Mode of operation, defaults to `rethrow`. - * - * - `rethrow`: If any errors are are passed into the handler in tests, it typically - * means that there is a bug in the application or test, so this mock will - * make these tests fail. - * - `log`: Sometimes it is desirable to test that an error is throw, for this case the `log` mode stores an - * array of errors in `$exceptionHandler.errors`, to allow later assertion of them. - * See {@link ngMock.$log#assertEmpty assertEmpty()} and - * {@link ngMock.$log#reset reset()} - */ - this.mode = function(mode) { - switch(mode) { - case 'rethrow': - handler = function(e) { - throw e; - }; - break; - case 'log': - var errors = []; - - handler = function(e) { - if (arguments.length == 1) { - errors.push(e); - } else { - errors.push([].slice.call(arguments, 0)); - } - }; - - handler.errors = errors; - break; - default: - throw Error("Unknown mode '" + mode + "', only 'log'/'rethrow' modes are allowed!"); - } - }; - - this.$get = function() { - return handler; - }; - - this.mode('rethrow'); -}; - - -/** - * @ngdoc service - * @name ngMock.$log - * - * @description - * Mock implementation of {@link ng.$log} that gathers all logged messages in arrays - * (one array per logging level). These arrays are exposed as `logs` property of each of the - * level-specific log function, e.g. for level `error` the array is exposed as `$log.error.logs`. - * - */ -angular.mock.$LogProvider = function() { - - function concat(array1, array2, index) { - return array1.concat(Array.prototype.slice.call(array2, index)); - } - - - this.$get = function () { - var $log = { - log: function() { $log.log.logs.push(concat([], arguments, 0)); }, - warn: function() { $log.warn.logs.push(concat([], arguments, 0)); }, - info: function() { $log.info.logs.push(concat([], arguments, 0)); }, - error: function() { $log.error.logs.push(concat([], arguments, 0)); } - }; - - /** - * @ngdoc method - * @name ngMock.$log#reset - * @methodOf ngMock.$log - * - * @description - * Reset all of the logging arrays to empty. - */ - $log.reset = function () { - /** - * @ngdoc property - * @name ngMock.$log#log.logs - * @propertyOf ngMock.$log - * - * @description - * Array of logged messages. - */ - $log.log.logs = []; - /** - * @ngdoc property - * @name ngMock.$log#warn.logs - * @propertyOf ngMock.$log - * - * @description - * Array of logged messages. - */ - $log.warn.logs = []; - /** - * @ngdoc property - * @name ngMock.$log#info.logs - * @propertyOf ngMock.$log - * - * @description - * Array of logged messages. - */ - $log.info.logs = []; - /** - * @ngdoc property - * @name ngMock.$log#error.logs - * @propertyOf ngMock.$log - * - * @description - * Array of logged messages. - */ - $log.error.logs = []; - }; - - /** - * @ngdoc method - * @name ngMock.$log#assertEmpty - * @methodOf ngMock.$log - * - * @description - * Assert that the all of the logging methods have no logged messages. If messages present, an exception is thrown. - */ - $log.assertEmpty = function() { - var errors = []; - angular.forEach(['error', 'warn', 'info', 'log'], function(logLevel) { - angular.forEach($log[logLevel].logs, function(log) { - angular.forEach(log, function (logItem) { - errors.push('MOCK $log (' + logLevel + '): ' + String(logItem) + '\n' + (logItem.stack || '')); - }); - }); - }); - if (errors.length) { - errors.unshift("Expected $log to be empty! Either a message was logged unexpectedly, or an expected " + - "log message was not checked and removed:"); - errors.push(''); - throw new Error(errors.join('\n---------\n')); - } - }; - - $log.reset(); - return $log; - }; -}; - - -(function() { - var R_ISO8061_STR = /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?:\:?(\d\d)(?:\:?(\d\d)(?:\.(\d{3}))?)?)?(Z|([+-])(\d\d):?(\d\d)))?$/; - - function jsonStringToDate(string){ - var match; - if (match = string.match(R_ISO8061_STR)) { - var date = new Date(0), - tzHour = 0, - tzMin = 0; - if (match[9]) { - tzHour = int(match[9] + match[10]); - tzMin = int(match[9] + match[11]); - } - date.setUTCFullYear(int(match[1]), int(match[2]) - 1, int(match[3])); - date.setUTCHours(int(match[4]||0) - tzHour, int(match[5]||0) - tzMin, int(match[6]||0), int(match[7]||0)); - return date; - } - return string; - } - - function int(str) { - return parseInt(str, 10); - } - - function padNumber(num, digits, trim) { - var neg = ''; - if (num < 0) { - neg = '-'; - num = -num; - } - num = '' + num; - while(num.length < digits) num = '0' + num; - if (trim) - num = num.substr(num.length - digits); - return neg + num; - } - - - /** - * @ngdoc object - * @name angular.mock.TzDate - * @description - * - * *NOTE*: this is not an injectable instance, just a globally available mock class of `Date`. - * - * Mock of the Date type which has its timezone specified via constroctor arg. - * - * The main purpose is to create Date-like instances with timezone fixed to the specified timezone - * offset, so that we can test code that depends on local timezone settings without dependency on - * the time zone settings of the machine where the code is running. - * - * @param {number} offset Offset of the *desired* timezone in hours (fractions will be honored) - * @param {(number|string)} timestamp Timestamp representing the desired time in *UTC* - * - * @example - * !!!! WARNING !!!!! - * This is not a complete Date object so only methods that were implemented can be called safely. - * To make matters worse, TzDate instances inherit stuff from Date via a prototype. - * - * We do our best to intercept calls to "unimplemented" methods, but since the list of methods is - * incomplete we might be missing some non-standard methods. This can result in errors like: - * "Date.prototype.foo called on incompatible Object". - * - * - * var newYearInBratislava = new TzDate(-1, '2009-12-31T23:00:00Z'); - * newYearInBratislava.getTimezoneOffset() => -60; - * newYearInBratislava.getFullYear() => 2010; - * newYearInBratislava.getMonth() => 0; - * newYearInBratislava.getDate() => 1; - * newYearInBratislava.getHours() => 0; - * newYearInBratislava.getMinutes() => 0; - * - * - */ - angular.mock.TzDate = function (offset, timestamp) { - var self = new Date(0); - if (angular.isString(timestamp)) { - var tsStr = timestamp; - - self.origDate = jsonStringToDate(timestamp); - - timestamp = self.origDate.getTime(); - if (isNaN(timestamp)) - throw { - name: "Illegal Argument", - message: "Arg '" + tsStr + "' passed into TzDate constructor is not a valid date string" - }; - } else { - self.origDate = new Date(timestamp); - } - - var localOffset = new Date(timestamp).getTimezoneOffset(); - self.offsetDiff = localOffset*60*1000 - offset*1000*60*60; - self.date = new Date(timestamp + self.offsetDiff); - - self.getTime = function() { - return self.date.getTime() - self.offsetDiff; - }; - - self.toLocaleDateString = function() { - return self.date.toLocaleDateString(); - }; - - self.getFullYear = function() { - return self.date.getFullYear(); - }; - - self.getMonth = function() { - return self.date.getMonth(); - }; - - self.getDate = function() { - return self.date.getDate(); - }; - - self.getHours = function() { - return self.date.getHours(); - }; - - self.getMinutes = function() { - return self.date.getMinutes(); - }; - - self.getSeconds = function() { - return self.date.getSeconds(); - }; - - self.getTimezoneOffset = function() { - return offset * 60; - }; - - self.getUTCFullYear = function() { - return self.origDate.getUTCFullYear(); - }; - - self.getUTCMonth = function() { - return self.origDate.getUTCMonth(); - }; - - self.getUTCDate = function() { - return self.origDate.getUTCDate(); - }; - - self.getUTCHours = function() { - return self.origDate.getUTCHours(); - }; - - self.getUTCMinutes = function() { - return self.origDate.getUTCMinutes(); - }; - - self.getUTCSeconds = function() { - return self.origDate.getUTCSeconds(); - }; - - self.getUTCMilliseconds = function() { - return self.origDate.getUTCMilliseconds(); - }; - - self.getDay = function() { - return self.date.getDay(); - }; - - // provide this method only on browsers that already have it - if (self.toISOString) { - self.toISOString = function() { - return padNumber(self.origDate.getUTCFullYear(), 4) + '-' + - padNumber(self.origDate.getUTCMonth() + 1, 2) + '-' + - padNumber(self.origDate.getUTCDate(), 2) + 'T' + - padNumber(self.origDate.getUTCHours(), 2) + ':' + - padNumber(self.origDate.getUTCMinutes(), 2) + ':' + - padNumber(self.origDate.getUTCSeconds(), 2) + '.' + - padNumber(self.origDate.getUTCMilliseconds(), 3) + 'Z' - } - } - - //hide all methods not implemented in this mock that the Date prototype exposes - var unimplementedMethods = ['getMilliseconds', 'getUTCDay', - 'getYear', 'setDate', 'setFullYear', 'setHours', 'setMilliseconds', - 'setMinutes', 'setMonth', 'setSeconds', 'setTime', 'setUTCDate', 'setUTCFullYear', - 'setUTCHours', 'setUTCMilliseconds', 'setUTCMinutes', 'setUTCMonth', 'setUTCSeconds', - 'setYear', 'toDateString', 'toGMTString', 'toJSON', 'toLocaleFormat', 'toLocaleString', - 'toLocaleTimeString', 'toSource', 'toString', 'toTimeString', 'toUTCString', 'valueOf']; - - angular.forEach(unimplementedMethods, function(methodName) { - self[methodName] = function() { - throw Error("Method '" + methodName + "' is not implemented in the TzDate mock"); - }; - }); - - return self; - }; - - //make "tzDateInstance instanceof Date" return true - angular.mock.TzDate.prototype = Date.prototype; -})(); - - -/** - * @ngdoc function - * @name angular.mock.dump - * @description - * - * *NOTE*: this is not an injectable instance, just a globally available function. - * - * Method for serializing common angular objects (scope, elements, etc..) into strings, useful for debugging. - * - * This method is also available on window, where it can be used to display objects on debug console. - * - * @param {*} object - any object to turn into string. - * @return {string} a serialized string of the argument - */ -angular.mock.dump = function(object) { - return serialize(object); - - function serialize(object) { - var out; - - if (angular.isElement(object)) { - object = angular.element(object); - out = angular.element(''); - angular.forEach(object, function(element) { - out.append(angular.element(element).clone()); - }); - out = out.html(); - } else if (angular.isArray(object)) { - out = []; - angular.forEach(object, function(o) { - out.push(serialize(o)); - }); - out = '[ ' + out.join(', ') + ' ]'; - } else if (angular.isObject(object)) { - if (angular.isFunction(object.$eval) && angular.isFunction(object.$apply)) { - out = serializeScope(object); - } else if (object instanceof Error) { - out = object.stack || ('' + object.name + ': ' + object.message); - } else { - out = angular.toJson(object, true); - } - } else { - out = String(object); - } - - return out; - } - - function serializeScope(scope, offset) { - offset = offset || ' '; - var log = [offset + 'Scope(' + scope.$id + '): {']; - for ( var key in scope ) { - if (scope.hasOwnProperty(key) && !key.match(/^(\$|this)/)) { - log.push(' ' + key + ': ' + angular.toJson(scope[key])); - } - } - var child = scope.$$childHead; - while(child) { - log.push(serializeScope(child, offset + ' ')); - child = child.$$nextSibling; - } - log.push('}'); - return log.join('\n' + offset); - } -}; - -/** - * @ngdoc object - * @name ngMock.$httpBackend - * @description - * Fake HTTP backend implementation suitable for unit testing application that use the - * {@link ng.$http $http service}. - * - * *Note*: For fake http backend implementation suitable for end-to-end testing or backend-less - * development please see {@link ngMockE2E.$httpBackend e2e $httpBackend mock}. - * - * During unit testing, we want our unit tests to run quickly and have no external dependencies so - * we don’t want to send {@link https://developer.mozilla.org/en/xmlhttprequest XHR} or - * {@link http://en.wikipedia.org/wiki/JSONP JSONP} requests to a real server. All we really need is - * to verify whether a certain request has been sent or not, or alternatively just let the - * application make requests, respond with pre-trained responses and assert that the end result is - * what we expect it to be. - * - * This mock implementation can be used to respond with static or dynamic responses via the - * `expect` and `when` apis and their shortcuts (`expectGET`, `whenPOST`, etc). - * - * When an Angular application needs some data from a server, it calls the $http service, which - * sends the request to a real server using $httpBackend service. With dependency injection, it is - * easy to inject $httpBackend mock (which has the same API as $httpBackend) and use it to verify - * the requests and respond with some testing data without sending a request to real server. - * - * There are two ways to specify what test data should be returned as http responses by the mock - * backend when the code under test makes http requests: - * - * - `$httpBackend.expect` - specifies a request expectation - * - `$httpBackend.when` - specifies a backend definition - * - * - * # Request Expectations vs Backend Definitions - * - * Request expectations provide a way to make assertions about requests made by the application and - * to define responses for those requests. The test will fail if the expected requests are not made - * or they are made in the wrong order. - * - * Backend definitions allow you to define a fake backend for your application which doesn't assert - * if a particular request was made or not, it just returns a trained response if a request is made. - * The test will pass whether or not the request gets made during testing. - * - * - * - * Request expectationsBackend definitions - * - * Syntax - * .expect(...).respond(...) - * .when(...).respond(...) - * - * - * Typical usage - * strict unit tests - * loose (black-box) unit testing - * - * - * Fulfills multiple requests - * NO - * YES - * - * - * Order of requests matters - * YES - * NO - * - * - * Request required - * YES - * NO - * - * - * Response required - * optional (see below) - * YES - * - * - * - * In cases where both backend definitions and request expectations are specified during unit - * testing, the request expectations are evaluated first. - * - * If a request expectation has no response specified, the algorithm will search your backend - * definitions for an appropriate response. - * - * If a request didn't match any expectation or if the expectation doesn't have the response - * defined, the backend definitions are evaluated in sequential order to see if any of them match - * the request. The response from the first matched definition is returned. - * - * - * # Flushing HTTP requests - * - * The $httpBackend used in production, always responds to requests with responses asynchronously. - * If we preserved this behavior in unit testing, we'd have to create async unit tests, which are - * hard to write, follow and maintain. At the same time the testing mock, can't respond - * synchronously because that would change the execution of the code under test. For this reason the - * mock $httpBackend has a `flush()` method, which allows the test to explicitly flush pending - * requests and thus preserving the async api of the backend, while allowing the test to execute - * synchronously. - * - * - * # Unit testing with mock $httpBackend - * - * - // controller - function MyController($scope, $http) { - $http.get('/auth.py').success(function(data) { - $scope.user = data; - }); - - this.saveMessage = function(message) { - $scope.status = 'Saving...'; - $http.post('/add-msg.py', message).success(function(response) { - $scope.status = ''; - }).error(function() { - $scope.status = 'ERROR!'; - }); - }; - } - - // testing controller - var $httpBackend; - - beforeEach(inject(function($injector) { - $httpBackend = $injector.get('$httpBackend'); - - // backend definition common for all tests - $httpBackend.when('GET', '/auth.py').respond({userId: 'userX'}, {'A-Token': 'xxx'}); - })); - - - afterEach(function() { - $httpBackend.verifyNoOutstandingExpectation(); - $httpBackend.verifyNoOutstandingRequest(); - }); - - - it('should fetch authentication token', function() { - $httpBackend.expectGET('/auth.py'); - var controller = scope.$new(MyController); - $httpBackend.flush(); - }); - - - it('should send msg to server', function() { - // now you don’t care about the authentication, but - // the controller will still send the request and - // $httpBackend will respond without you having to - // specify the expectation and response for this request - $httpBackend.expectPOST('/add-msg.py', 'message content').respond(201, ''); - - var controller = scope.$new(MyController); - $httpBackend.flush(); - controller.saveMessage('message content'); - expect(controller.status).toBe('Saving...'); - $httpBackend.flush(); - expect(controller.status).toBe(''); - }); - - - it('should send auth header', function() { - $httpBackend.expectPOST('/add-msg.py', undefined, function(headers) { - // check if the header was send, if it wasn't the expectation won't - // match the request and the test will fail - return headers['Authorization'] == 'xxx'; - }).respond(201, ''); - - var controller = scope.$new(MyController); - controller.saveMessage('whatever'); - $httpBackend.flush(); - }); - - */ -angular.mock.$HttpBackendProvider = function() { - this.$get = [createHttpBackendMock]; -}; - -/** - * General factory function for $httpBackend mock. - * Returns instance for unit testing (when no arguments specified): - * - passing through is disabled - * - auto flushing is disabled - * - * Returns instance for e2e testing (when `$delegate` and `$browser` specified): - * - passing through (delegating request to real backend) is enabled - * - auto flushing is enabled - * - * @param {Object=} $delegate Real $httpBackend instance (allow passing through if specified) - * @param {Object=} $browser Auto-flushing enabled if specified - * @return {Object} Instance of $httpBackend mock - */ -function createHttpBackendMock($delegate, $browser) { - var definitions = [], - expectations = [], - responses = [], - responsesPush = angular.bind(responses, responses.push); - - function createResponse(status, data, headers) { - if (angular.isFunction(status)) return status; - - return function() { - return angular.isNumber(status) - ? [status, data, headers] - : [200, status, data]; - }; - } - - // TODO(vojta): change params to: method, url, data, headers, callback - function $httpBackend(method, url, data, callback, headers) { - var xhr = new MockXhr(), - expectation = expectations[0], - wasExpected = false; - - function prettyPrint(data) { - return (angular.isString(data) || angular.isFunction(data) || data instanceof RegExp) - ? data - : angular.toJson(data); - } - - if (expectation && expectation.match(method, url)) { - if (!expectation.matchData(data)) - throw Error('Expected ' + expectation + ' with different data\n' + - 'EXPECTED: ' + prettyPrint(expectation.data) + '\nGOT: ' + data); - - if (!expectation.matchHeaders(headers)) - throw Error('Expected ' + expectation + ' with different headers\n' + - 'EXPECTED: ' + prettyPrint(expectation.headers) + '\nGOT: ' + - prettyPrint(headers)); - - expectations.shift(); - - if (expectation.response) { - responses.push(function() { - var response = expectation.response(method, url, data, headers); - xhr.$$respHeaders = response[2]; - callback(response[0], response[1], xhr.getAllResponseHeaders()); - }); - return; - } - wasExpected = true; - } - - var i = -1, definition; - while ((definition = definitions[++i])) { - if (definition.match(method, url, data, headers || {})) { - if (definition.response) { - // if $browser specified, we do auto flush all requests - ($browser ? $browser.defer : responsesPush)(function() { - var response = definition.response(method, url, data, headers); - xhr.$$respHeaders = response[2]; - callback(response[0], response[1], xhr.getAllResponseHeaders()); - }); - } else if (definition.passThrough) { - $delegate(method, url, data, callback, headers); - } else throw Error('No response defined !'); - return; - } - } - throw wasExpected ? - Error('No response defined !') : - Error('Unexpected request: ' + method + ' ' + url + '\n' + - (expectation ? 'Expected ' + expectation : 'No more request expected')); - } - - /** - * @ngdoc method - * @name ngMock.$httpBackend#when - * @methodOf ngMock.$httpBackend - * @description - * Creates a new backend definition. - * - * @param {string} method HTTP method. - * @param {string|RegExp} url HTTP url. - * @param {(string|RegExp)=} data HTTP request body. - * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header - * object and returns true if the headers match the current definition. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - * - * - respond – `{function([status,] data[, headers])|function(function(method, url, data, headers)}` - * – The respond method takes a set of static data to be returned or a function that can return - * an array containing response status (number), response data (string) and response headers - * (Object). - */ - $httpBackend.when = function(method, url, data, headers) { - var definition = new MockHttpExpectation(method, url, data, headers), - chain = { - respond: function(status, data, headers) { - definition.response = createResponse(status, data, headers); - } - }; - - if ($browser) { - chain.passThrough = function() { - definition.passThrough = true; - }; - } - - definitions.push(definition); - return chain; - }; - - /** - * @ngdoc method - * @name ngMock.$httpBackend#whenGET - * @methodOf ngMock.$httpBackend - * @description - * Creates a new backend definition for GET requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(Object|function(Object))=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - - /** - * @ngdoc method - * @name ngMock.$httpBackend#whenHEAD - * @methodOf ngMock.$httpBackend - * @description - * Creates a new backend definition for HEAD requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(Object|function(Object))=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - - /** - * @ngdoc method - * @name ngMock.$httpBackend#whenDELETE - * @methodOf ngMock.$httpBackend - * @description - * Creates a new backend definition for DELETE requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(Object|function(Object))=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - - /** - * @ngdoc method - * @name ngMock.$httpBackend#whenPOST - * @methodOf ngMock.$httpBackend - * @description - * Creates a new backend definition for POST requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(string|RegExp)=} data HTTP request body. - * @param {(Object|function(Object))=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - - /** - * @ngdoc method - * @name ngMock.$httpBackend#whenPUT - * @methodOf ngMock.$httpBackend - * @description - * Creates a new backend definition for PUT requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(string|RegExp)=} data HTTP request body. - * @param {(Object|function(Object))=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - - /** - * @ngdoc method - * @name ngMock.$httpBackend#whenJSONP - * @methodOf ngMock.$httpBackend - * @description - * Creates a new backend definition for JSONP requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - createShortMethods('when'); - - - /** - * @ngdoc method - * @name ngMock.$httpBackend#expect - * @methodOf ngMock.$httpBackend - * @description - * Creates a new request expectation. - * - * @param {string} method HTTP method. - * @param {string|RegExp} url HTTP url. - * @param {(string|RegExp)=} data HTTP request body. - * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header - * object and returns true if the headers match the current expectation. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - * - * - respond – `{function([status,] data[, headers])|function(function(method, url, data, headers)}` - * – The respond method takes a set of static data to be returned or a function that can return - * an array containing response status (number), response data (string) and response headers - * (Object). - */ - $httpBackend.expect = function(method, url, data, headers) { - var expectation = new MockHttpExpectation(method, url, data, headers); - expectations.push(expectation); - return { - respond: function(status, data, headers) { - expectation.response = createResponse(status, data, headers); - } - }; - }; - - - /** - * @ngdoc method - * @name ngMock.$httpBackend#expectGET - * @methodOf ngMock.$httpBackend - * @description - * Creates a new request expectation for GET requests. For more info see `expect()`. - * - * @param {string|RegExp} url HTTP url. - * @param {Object=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. See #expect for more info. - */ - - /** - * @ngdoc method - * @name ngMock.$httpBackend#expectHEAD - * @methodOf ngMock.$httpBackend - * @description - * Creates a new request expectation for HEAD requests. For more info see `expect()`. - * - * @param {string|RegExp} url HTTP url. - * @param {Object=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - - /** - * @ngdoc method - * @name ngMock.$httpBackend#expectDELETE - * @methodOf ngMock.$httpBackend - * @description - * Creates a new request expectation for DELETE requests. For more info see `expect()`. - * - * @param {string|RegExp} url HTTP url. - * @param {Object=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - - /** - * @ngdoc method - * @name ngMock.$httpBackend#expectPOST - * @methodOf ngMock.$httpBackend - * @description - * Creates a new request expectation for POST requests. For more info see `expect()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(string|RegExp)=} data HTTP request body. - * @param {Object=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - - /** - * @ngdoc method - * @name ngMock.$httpBackend#expectPUT - * @methodOf ngMock.$httpBackend - * @description - * Creates a new request expectation for PUT requests. For more info see `expect()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(string|RegExp)=} data HTTP request body. - * @param {Object=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - - /** - * @ngdoc method - * @name ngMock.$httpBackend#expectPATCH - * @methodOf ngMock.$httpBackend - * @description - * Creates a new request expectation for PATCH requests. For more info see `expect()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(string|RegExp)=} data HTTP request body. - * @param {Object=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - - /** - * @ngdoc method - * @name ngMock.$httpBackend#expectJSONP - * @methodOf ngMock.$httpBackend - * @description - * Creates a new request expectation for JSONP requests. For more info see `expect()`. - * - * @param {string|RegExp} url HTTP url. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - createShortMethods('expect'); - - - /** - * @ngdoc method - * @name ngMock.$httpBackend#flush - * @methodOf ngMock.$httpBackend - * @description - * Flushes all pending requests using the trained responses. - * - * @param {number=} count Number of responses to flush (in the order they arrived). If undefined, - * all pending requests will be flushed. If there are no pending requests when the flush method - * is called an exception is thrown (as this typically a sign of programming error). - */ - $httpBackend.flush = function(count) { - if (!responses.length) throw Error('No pending request to flush !'); - - if (angular.isDefined(count)) { - while (count--) { - if (!responses.length) throw Error('No more pending request to flush !'); - responses.shift()(); - } - } else { - while (responses.length) { - responses.shift()(); - } - } - $httpBackend.verifyNoOutstandingExpectation(); - }; - - - /** - * @ngdoc method - * @name ngMock.$httpBackend#verifyNoOutstandingExpectation - * @methodOf ngMock.$httpBackend - * @description - * Verifies that all of the requests defined via the `expect` api were made. If any of the - * requests were not made, verifyNoOutstandingExpectation throws an exception. - * - * Typically, you would call this method following each test case that asserts requests using an - * "afterEach" clause. - * - * - * afterEach($httpBackend.verifyExpectations); - * - */ - $httpBackend.verifyNoOutstandingExpectation = function() { - if (expectations.length) { - throw Error('Unsatisfied requests: ' + expectations.join(', ')); - } - }; - - - /** - * @ngdoc method - * @name ngMock.$httpBackend#verifyNoOutstandingRequest - * @methodOf ngMock.$httpBackend - * @description - * Verifies that there are no outstanding requests that need to be flushed. - * - * Typically, you would call this method following each test case that asserts requests using an - * "afterEach" clause. - * - * - * afterEach($httpBackend.verifyNoOutstandingRequest); - * - */ - $httpBackend.verifyNoOutstandingRequest = function() { - if (responses.length) { - throw Error('Unflushed requests: ' + responses.length); - } - }; - - - /** - * @ngdoc method - * @name ngMock.$httpBackend#resetExpectations - * @methodOf ngMock.$httpBackend - * @description - * Resets all request expectations, but preserves all backend definitions. Typically, you would - * call resetExpectations during a multiple-phase test when you want to reuse the same instance of - * $httpBackend mock. - */ - $httpBackend.resetExpectations = function() { - expectations.length = 0; - responses.length = 0; - }; - - return $httpBackend; - - - function createShortMethods(prefix) { - angular.forEach(['GET', 'DELETE', 'JSONP'], function(method) { - $httpBackend[prefix + method] = function(url, headers) { - return $httpBackend[prefix](method, url, undefined, headers) - } - }); - - angular.forEach(['PUT', 'POST', 'PATCH'], function(method) { - $httpBackend[prefix + method] = function(url, data, headers) { - return $httpBackend[prefix](method, url, data, headers) - } - }); - } -} - -function MockHttpExpectation(method, url, data, headers) { - - this.data = data; - this.headers = headers; - - this.match = function(m, u, d, h) { - if (method != m) return false; - if (!this.matchUrl(u)) return false; - if (angular.isDefined(d) && !this.matchData(d)) return false; - if (angular.isDefined(h) && !this.matchHeaders(h)) return false; - return true; - }; - - this.matchUrl = function(u) { - if (!url) return true; - if (angular.isFunction(url.test)) return url.test(u); - return url == u; - }; - - this.matchHeaders = function(h) { - if (angular.isUndefined(headers)) return true; - if (angular.isFunction(headers)) return headers(h); - return angular.equals(headers, h); - }; - - this.matchData = function(d) { - if (angular.isUndefined(data)) return true; - if (data && angular.isFunction(data.test)) return data.test(d); - if (data && !angular.isString(data)) return angular.toJson(data) == d; - return data == d; - }; - - this.toString = function() { - return method + ' ' + url; - }; -} - -function MockXhr() { - - // hack for testing $http, $httpBackend - MockXhr.$$lastInstance = this; - - this.open = function(method, url, async) { - this.$$method = method; - this.$$url = url; - this.$$async = async; - this.$$reqHeaders = {}; - this.$$respHeaders = {}; - }; - - this.send = function(data) { - this.$$data = data; - }; - - this.setRequestHeader = function(key, value) { - this.$$reqHeaders[key] = value; - }; - - this.getResponseHeader = function(name) { - // the lookup must be case insensitive, that's why we try two quick lookups and full scan at last - var header = this.$$respHeaders[name]; - if (header) return header; - - name = angular.lowercase(name); - header = this.$$respHeaders[name]; - if (header) return header; - - header = undefined; - angular.forEach(this.$$respHeaders, function(headerVal, headerName) { - if (!header && angular.lowercase(headerName) == name) header = headerVal; - }); - return header; - }; - - this.getAllResponseHeaders = function() { - var lines = []; - - angular.forEach(this.$$respHeaders, function(value, key) { - lines.push(key + ': ' + value); - }); - return lines.join('\n'); - }; - - this.abort = angular.noop; -} - - -/** - * @ngdoc function - * @name ngMock.$timeout - * @description - * - * This service is just a simple decorator for {@link ng.$timeout $timeout} service - * that adds a "flush" method. - */ - -/** - * @ngdoc method - * @name ngMock.$timeout#flush - * @methodOf ngMock.$timeout - * @description - * - * Flushes the queue of pending tasks. - */ - -/** - * - */ -angular.mock.$RootElementProvider = function() { - this.$get = function() { - return angular.element(''); - } -}; - -/** - * @ngdoc overview - * @name ngMock - * @description - * - * The `ngMock` is an angular module which is used with `ng` module and adds unit-test configuration as well as useful - * mocks to the {@link AUTO.$injector $injector}. - */ -angular.module('ngMock', ['ng']).provider({ - $browser: angular.mock.$BrowserProvider, - $exceptionHandler: angular.mock.$ExceptionHandlerProvider, - $log: angular.mock.$LogProvider, - $httpBackend: angular.mock.$HttpBackendProvider, - $rootElement: angular.mock.$RootElementProvider -}).config(function($provide) { - $provide.decorator('$timeout', function($delegate, $browser) { - $delegate.flush = function() { - $browser.defer.flush(); - }; - return $delegate; - }); -}); - - -/** - * @ngdoc overview - * @name ngMockE2E - * @description - * - * The `ngMockE2E` is an angular module which contains mocks suitable for end-to-end testing. - * Currently there is only one mock present in this module - - * the {@link ngMockE2E.$httpBackend e2e $httpBackend} mock. - */ -angular.module('ngMockE2E', ['ng']).config(function($provide) { - $provide.decorator('$httpBackend', angular.mock.e2e.$httpBackendDecorator); -}); - -/** - * @ngdoc object - * @name ngMockE2E.$httpBackend - * @description - * Fake HTTP backend implementation suitable for end-to-end testing or backend-less development of - * applications that use the {@link ng.$http $http service}. - * - * *Note*: For fake http backend implementation suitable for unit testing please see - * {@link ngMock.$httpBackend unit-testing $httpBackend mock}. - * - * This implementation can be used to respond with static or dynamic responses via the `when` api - * and its shortcuts (`whenGET`, `whenPOST`, etc) and optionally pass through requests to the - * real $httpBackend for specific requests (e.g. to interact with certain remote apis or to fetch - * templates from a webserver). - * - * As opposed to unit-testing, in an end-to-end testing scenario or in scenario when an application - * is being developed with the real backend api replaced with a mock, it is often desirable for - * certain category of requests to bypass the mock and issue a real http request (e.g. to fetch - * templates or static files from the webserver). To configure the backend with this behavior - * use the `passThrough` request handler of `when` instead of `respond`. - * - * Additionally, we don't want to manually have to flush mocked out requests like we do during unit - * testing. For this reason the e2e $httpBackend automatically flushes mocked out requests - * automatically, closely simulating the behavior of the XMLHttpRequest object. - * - * To setup the application to run with this http backend, you have to create a module that depends - * on the `ngMockE2E` and your application modules and defines the fake backend: - * - * - * myAppDev = angular.module('myAppDev', ['myApp', 'ngMockE2E']); - * myAppDev.run(function($httpBackend) { - * phones = [{name: 'phone1'}, {name: 'phone2'}]; - * - * // returns the current list of phones - * $httpBackend.whenGET('/phones').respond(phones); - * - * // adds a new phone to the phones array - * $httpBackend.whenPOST('/phones').respond(function(method, url, data) { - * phones.push(angular.fromJSON(data)); - * }); - * $httpBackend.whenGET(/^\/templates\//).passThrough(); - * //... - * }); - * - * - * Afterwards, bootstrap your app with this new module. - */ - -/** - * @ngdoc method - * @name ngMockE2E.$httpBackend#when - * @methodOf ngMockE2E.$httpBackend - * @description - * Creates a new backend definition. - * - * @param {string} method HTTP method. - * @param {string|RegExp} url HTTP url. - * @param {(string|RegExp)=} data HTTP request body. - * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header - * object and returns true if the headers match the current definition. - * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that - * control how a matched request is handled. - * - * - respond – `{function([status,] data[, headers])|function(function(method, url, data, headers)}` - * – The respond method takes a set of static data to be returned or a function that can return - * an array containing response status (number), response data (string) and response headers - * (Object). - * - passThrough – `{function()}` – Any request matching a backend definition with `passThrough` - * handler, will be pass through to the real backend (an XHR request will be made to the - * server. - */ - -/** - * @ngdoc method - * @name ngMockE2E.$httpBackend#whenGET - * @methodOf ngMockE2E.$httpBackend - * @description - * Creates a new backend definition for GET requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(Object|function(Object))=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that - * control how a matched request is handled. - */ - -/** - * @ngdoc method - * @name ngMockE2E.$httpBackend#whenHEAD - * @methodOf ngMockE2E.$httpBackend - * @description - * Creates a new backend definition for HEAD requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(Object|function(Object))=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that - * control how a matched request is handled. - */ - -/** - * @ngdoc method - * @name ngMockE2E.$httpBackend#whenDELETE - * @methodOf ngMockE2E.$httpBackend - * @description - * Creates a new backend definition for DELETE requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(Object|function(Object))=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that - * control how a matched request is handled. - */ - -/** - * @ngdoc method - * @name ngMockE2E.$httpBackend#whenPOST - * @methodOf ngMockE2E.$httpBackend - * @description - * Creates a new backend definition for POST requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(string|RegExp)=} data HTTP request body. - * @param {(Object|function(Object))=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that - * control how a matched request is handled. - */ - -/** - * @ngdoc method - * @name ngMockE2E.$httpBackend#whenPUT - * @methodOf ngMockE2E.$httpBackend - * @description - * Creates a new backend definition for PUT requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(string|RegExp)=} data HTTP request body. - * @param {(Object|function(Object))=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that - * control how a matched request is handled. - */ - -/** - * @ngdoc method - * @name ngMockE2E.$httpBackend#whenPATCH - * @methodOf ngMockE2E.$httpBackend - * @description - * Creates a new backend definition for PATCH requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @param {(string|RegExp)=} data HTTP request body. - * @param {(Object|function(Object))=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that - * control how a matched request is handled. - */ - -/** - * @ngdoc method - * @name ngMockE2E.$httpBackend#whenJSONP - * @methodOf ngMockE2E.$httpBackend - * @description - * Creates a new backend definition for JSONP requests. For more info see `when()`. - * - * @param {string|RegExp} url HTTP url. - * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that - * control how a matched request is handled. - */ -angular.mock.e2e = {}; -angular.mock.e2e.$httpBackendDecorator = ['$delegate', '$browser', createHttpBackendMock]; - - -angular.mock.clearDataCache = function() { - var key, - cache = angular.element.cache; - - for(key in cache) { - if (cache.hasOwnProperty(key)) { - var handle = cache[key].handle; - - handle && angular.element(handle.elem).unbind(); - delete cache[key]; - } - } -}; - - -window.jstestdriver && (function(window) { - /** - * Global method to output any number of objects into JSTD console. Useful for debugging. - */ - window.dump = function() { - var args = []; - angular.forEach(arguments, function(arg) { - args.push(angular.mock.dump(arg)); - }); - jstestdriver.console.log.apply(jstestdriver.console, args); - if (window.console) { - window.console.log.apply(window.console, args); - } - }; -})(window); - - -window.jasmine && (function(window) { - - afterEach(function() { - var spec = getCurrentSpec(); - var injector = spec.$injector; - - spec.$injector = null; - spec.$modules = null; - - if (injector) { - injector.get('$rootElement').unbind(); - injector.get('$browser').pollFns.length = 0; - } - - angular.mock.clearDataCache(); - - // clean up jquery's fragment cache - angular.forEach(angular.element.fragments, function(val, key) { - delete angular.element.fragments[key]; - }); - - MockXhr.$$lastInstance = null; - - angular.forEach(angular.callbacks, function(val, key) { - delete angular.callbacks[key]; - }); - angular.callbacks.counter = 0; - }); - - function getCurrentSpec() { - return jasmine.getEnv().currentSpec; - } - - function isSpecRunning() { - var spec = getCurrentSpec(); - return spec && spec.queue.running; - } - - /** - * @ngdoc function - * @name angular.mock.module - * @description - * - * *NOTE*: This is function is also published on window for easy access. - * *NOTE*: Only available with {@link http://pivotal.github.com/jasmine/ jasmine}. - * - * This function registers a module configuration code. It collects the configuration information - * which will be used when the injector is created by {@link angular.mock.inject inject}. - * - * See {@link angular.mock.inject inject} for usage example - * - * @param {...(string|Function)} fns any number of modules which are represented as string - * aliases or as anonymous module initialization functions. The modules are used to - * configure the injector. The 'ng' and 'ngMock' modules are automatically loaded. - */ - window.module = angular.mock.module = function() { - var moduleFns = Array.prototype.slice.call(arguments, 0); - return isSpecRunning() ? workFn() : workFn; - ///////////////////// - function workFn() { - var spec = getCurrentSpec(); - if (spec.$injector) { - throw Error('Injector already created, can not register a module!'); - } else { - var modules = spec.$modules || (spec.$modules = []); - angular.forEach(moduleFns, function(module) { - modules.push(module); - }); - } - } - }; - - /** - * @ngdoc function - * @name angular.mock.inject - * @description - * - * *NOTE*: This is function is also published on window for easy access. - * *NOTE*: Only available with {@link http://pivotal.github.com/jasmine/ jasmine}. - * - * The inject function wraps a function into an injectable function. The inject() creates new - * instance of {@link AUTO.$injector $injector} per test, which is then used for - * resolving references. - * - * See also {@link angular.mock.module module} - * - * Example of what a typical jasmine tests looks like with the inject method. - * - * - * angular.module('myApplicationModule', []) - * .value('mode', 'app') - * .value('version', 'v1.0.1'); - * - * - * describe('MyApp', function() { - * - * // You need to load modules that you want to test, - * // it loads only the "ng" module by default. - * beforeEach(module('myApplicationModule')); - * - * - * // inject() is used to inject arguments of all given functions - * it('should provide a version', inject(function(mode, version) { - * expect(version).toEqual('v1.0.1'); - * expect(mode).toEqual('app'); - * })); - * - * - * // The inject and module method can also be used inside of the it or beforeEach - * it('should override a version and test the new version is injected', function() { - * // module() takes functions or strings (module aliases) - * module(function($provide) { - * $provide.value('version', 'overridden'); // override version here - * }); - * - * inject(function(version) { - * expect(version).toEqual('overridden'); - * }); - * )); - * }); - * - * - * - * @param {...Function} fns any number of functions which will be injected using the injector. - */ - window.inject = angular.mock.inject = function() { - var blockFns = Array.prototype.slice.call(arguments, 0); - var errorForStack = new Error('Declaration Location'); - return isSpecRunning() ? workFn() : workFn; - ///////////////////// - function workFn() { - var spec = getCurrentSpec(); - var modules = spec.$modules || []; - modules.unshift('ngMock'); - modules.unshift('ng'); - var injector = spec.$injector; - if (!injector) { - injector = spec.$injector = angular.injector(modules); - } - for(var i = 0, ii = blockFns.length; i < ii; i++) { - try { - injector.invoke(blockFns[i] || angular.noop, this); - } catch (e) { - if(e.stack) e.stack += '\n' + errorForStack.stack; - throw e; - } finally { - errorForStack = null; - } - } - } - }; -})(window); diff --git a/misc/test-lib/angular.js b/misc/test-lib/angular.js deleted file mode 100644 index 68b33c7f92..0000000000 --- a/misc/test-lib/angular.js +++ /dev/null @@ -1,14733 +0,0 @@ -/** - * @license AngularJS v1.0.5 - * (c) 2010-2012 Google, Inc. http://angularjs.org - * License: MIT - */ -(function(window, document, undefined) { -'use strict'; - -//////////////////////////////////// - -/** - * @ngdoc function - * @name angular.lowercase - * @function - * - * @description Converts the specified string to lowercase. - * @param {string} string String to be converted to lowercase. - * @returns {string} Lowercased string. - */ -var lowercase = function(string){return isString(string) ? string.toLowerCase() : string;}; - - -/** - * @ngdoc function - * @name angular.uppercase - * @function - * - * @description Converts the specified string to uppercase. - * @param {string} string String to be converted to uppercase. - * @returns {string} Uppercased string. - */ -var uppercase = function(string){return isString(string) ? string.toUpperCase() : string;}; - - -var manualLowercase = function(s) { - return isString(s) - ? s.replace(/[A-Z]/g, function(ch) {return fromCharCode(ch.charCodeAt(0) | 32);}) - : s; -}; -var manualUppercase = function(s) { - return isString(s) - ? s.replace(/[a-z]/g, function(ch) {return fromCharCode(ch.charCodeAt(0) & ~32);}) - : s; -}; - - -// String#toLowerCase and String#toUpperCase don't produce correct results in browsers with Turkish -// locale, for this reason we need to detect this case and redefine lowercase/uppercase methods -// with correct but slower alternatives. -if ('i' !== 'I'.toLowerCase()) { - lowercase = manualLowercase; - uppercase = manualUppercase; -} - -function fromCharCode(code) {return String.fromCharCode(code);} - - -var /** holds major version number for IE or NaN for real browsers */ - msie = int((/msie (\d+)/.exec(lowercase(navigator.userAgent)) || [])[1]), - jqLite, // delay binding since jQuery could be loaded after us. - jQuery, // delay binding - slice = [].slice, - push = [].push, - toString = Object.prototype.toString, - - /** @name angular */ - angular = window.angular || (window.angular = {}), - angularModule, - nodeName_, - uid = ['0', '0', '0']; - -/** - * @ngdoc function - * @name angular.forEach - * @function - * - * @description - * Invokes the `iterator` function once for each item in `obj` collection, which can be either an - * object or an array. The `iterator` function is invoked with `iterator(value, key)`, where `value` - * is the value of an object property or an array element and `key` is the object property key or - * array element index. Specifying a `context` for the function is optional. - * - * Note: this function was previously known as `angular.foreach`. - * - - var values = {name: 'misko', gender: 'male'}; - var log = []; - angular.forEach(values, function(value, key){ - this.push(key + ': ' + value); - }, log); - expect(log).toEqual(['name: misko', 'gender:male']); - - * - * @param {Object|Array} obj Object to iterate over. - * @param {Function} iterator Iterator function. - * @param {Object=} context Object to become context (`this`) for the iterator function. - * @returns {Object|Array} Reference to `obj`. - */ - - -/** - * @private - * @param {*} obj - * @return {boolean} Returns true if `obj` is an array or array-like object (NodeList, Arguments, ...) - */ -function isArrayLike(obj) { - if (!obj || (typeof obj.length !== 'number')) return false; - - // We have on object which has length property. Should we treat it as array? - if (typeof obj.hasOwnProperty != 'function' && - typeof obj.constructor != 'function') { - // This is here for IE8: it is a bogus object treat it as array; - return true; - } else { - return obj instanceof JQLite || // JQLite - (jQuery && obj instanceof jQuery) || // jQuery - toString.call(obj) !== '[object Object]' || // some browser native object - typeof obj.callee === 'function'; // arguments (on IE8 looks like regular obj) - } -} - - -function forEach(obj, iterator, context) { - var key; - if (obj) { - if (isFunction(obj)){ - for (key in obj) { - if (key != 'prototype' && key != 'length' && key != 'name' && obj.hasOwnProperty(key)) { - iterator.call(context, obj[key], key); - } - } - } else if (obj.forEach && obj.forEach !== forEach) { - obj.forEach(iterator, context); - } else if (isArrayLike(obj)) { - for (key = 0; key < obj.length; key++) - iterator.call(context, obj[key], key); - } else { - for (key in obj) { - if (obj.hasOwnProperty(key)) { - iterator.call(context, obj[key], key); - } - } - } - } - return obj; -} - -function sortedKeys(obj) { - var keys = []; - for (var key in obj) { - if (obj.hasOwnProperty(key)) { - keys.push(key); - } - } - return keys.sort(); -} - -function forEachSorted(obj, iterator, context) { - var keys = sortedKeys(obj); - for ( var i = 0; i < keys.length; i++) { - iterator.call(context, obj[keys[i]], keys[i]); - } - return keys; -} - - -/** - * when using forEach the params are value, key, but it is often useful to have key, value. - * @param {function(string, *)} iteratorFn - * @returns {function(*, string)} - */ -function reverseParams(iteratorFn) { - return function(value, key) { iteratorFn(key, value) }; -} - -/** - * A consistent way of creating unique IDs in angular. The ID is a sequence of alpha numeric - * characters such as '012ABC'. The reason why we are not using simply a number counter is that - * the number string gets longer over time, and it can also overflow, where as the nextId - * will grow much slower, it is a string, and it will never overflow. - * - * @returns an unique alpha-numeric string - */ -function nextUid() { - var index = uid.length; - var digit; - - while(index) { - index--; - digit = uid[index].charCodeAt(0); - if (digit == 57 /*'9'*/) { - uid[index] = 'A'; - return uid.join(''); - } - if (digit == 90 /*'Z'*/) { - uid[index] = '0'; - } else { - uid[index] = String.fromCharCode(digit + 1); - return uid.join(''); - } - } - uid.unshift('0'); - return uid.join(''); -} - -/** - * @ngdoc function - * @name angular.extend - * @function - * - * @description - * Extends the destination object `dst` by copying all of the properties from the `src` object(s) - * to `dst`. You can specify multiple `src` objects. - * - * @param {Object} dst Destination object. - * @param {...Object} src Source object(s). - */ -function extend(dst) { - forEach(arguments, function(obj){ - if (obj !== dst) { - forEach(obj, function(value, key){ - dst[key] = value; - }); - } - }); - return dst; -} - -function int(str) { - return parseInt(str, 10); -} - - -function inherit(parent, extra) { - return extend(new (extend(function() {}, {prototype:parent}))(), extra); -} - - -/** - * @ngdoc function - * @name angular.noop - * @function - * - * @description - * A function that performs no operations. This function can be useful when writing code in the - * functional style. - - function foo(callback) { - var result = calculateResult(); - (callback || angular.noop)(result); - } - - */ -function noop() {} -noop.$inject = []; - - -/** - * @ngdoc function - * @name angular.identity - * @function - * - * @description - * A function that returns its first argument. This function is useful when writing code in the - * functional style. - * - - function transformer(transformationFn, value) { - return (transformationFn || identity)(value); - }; - - */ -function identity($) {return $;} -identity.$inject = []; - - -function valueFn(value) {return function() {return value;};} - -/** - * @ngdoc function - * @name angular.isUndefined - * @function - * - * @description - * Determines if a reference is undefined. - * - * @param {*} value Reference to check. - * @returns {boolean} True if `value` is undefined. - */ -function isUndefined(value){return typeof value == 'undefined';} - - -/** - * @ngdoc function - * @name angular.isDefined - * @function - * - * @description - * Determines if a reference is defined. - * - * @param {*} value Reference to check. - * @returns {boolean} True if `value` is defined. - */ -function isDefined(value){return typeof value != 'undefined';} - - -/** - * @ngdoc function - * @name angular.isObject - * @function - * - * @description - * Determines if a reference is an `Object`. Unlike `typeof` in JavaScript, `null`s are not - * considered to be objects. - * - * @param {*} value Reference to check. - * @returns {boolean} True if `value` is an `Object` but not `null`. - */ -function isObject(value){return value != null && typeof value == 'object';} - - -/** - * @ngdoc function - * @name angular.isString - * @function - * - * @description - * Determines if a reference is a `String`. - * - * @param {*} value Reference to check. - * @returns {boolean} True if `value` is a `String`. - */ -function isString(value){return typeof value == 'string';} - - -/** - * @ngdoc function - * @name angular.isNumber - * @function - * - * @description - * Determines if a reference is a `Number`. - * - * @param {*} value Reference to check. - * @returns {boolean} True if `value` is a `Number`. - */ -function isNumber(value){return typeof value == 'number';} - - -/** - * @ngdoc function - * @name angular.isDate - * @function - * - * @description - * Determines if a value is a date. - * - * @param {*} value Reference to check. - * @returns {boolean} True if `value` is a `Date`. - */ -function isDate(value){ - return toString.apply(value) == '[object Date]'; -} - - -/** - * @ngdoc function - * @name angular.isArray - * @function - * - * @description - * Determines if a reference is an `Array`. - * - * @param {*} value Reference to check. - * @returns {boolean} True if `value` is an `Array`. - */ -function isArray(value) { - return toString.apply(value) == '[object Array]'; -} - - -/** - * @ngdoc function - * @name angular.isFunction - * @function - * - * @description - * Determines if a reference is a `Function`. - * - * @param {*} value Reference to check. - * @returns {boolean} True if `value` is a `Function`. - */ -function isFunction(value){return typeof value == 'function';} - - -/** - * Checks if `obj` is a window object. - * - * @private - * @param {*} obj Object to check - * @returns {boolean} True if `obj` is a window obj. - */ -function isWindow(obj) { - return obj && obj.document && obj.location && obj.alert && obj.setInterval; -} - - -function isScope(obj) { - return obj && obj.$evalAsync && obj.$watch; -} - - -function isFile(obj) { - return toString.apply(obj) === '[object File]'; -} - - -function isBoolean(value) { - return typeof value == 'boolean'; -} - - -function trim(value) { - return isString(value) ? value.replace(/^\s*/, '').replace(/\s*$/, '') : value; -} - -/** - * @ngdoc function - * @name angular.isElement - * @function - * - * @description - * Determines if a reference is a DOM element (or wrapped jQuery element). - * - * @param {*} value Reference to check. - * @returns {boolean} True if `value` is a DOM element (or wrapped jQuery element). - */ -function isElement(node) { - return node && - (node.nodeName // we are a direct element - || (node.bind && node.find)); // we have a bind and find method part of jQuery API -} - -/** - * @param str 'key1,key2,...' - * @returns {object} in the form of {key1:true, key2:true, ...} - */ -function makeMap(str){ - var obj = {}, items = str.split(","), i; - for ( i = 0; i < items.length; i++ ) - obj[ items[i] ] = true; - return obj; -} - - -if (msie < 9) { - nodeName_ = function(element) { - element = element.nodeName ? element : element[0]; - return (element.scopeName && element.scopeName != 'HTML') - ? uppercase(element.scopeName + ':' + element.nodeName) : element.nodeName; - }; -} else { - nodeName_ = function(element) { - return element.nodeName ? element.nodeName : element[0].nodeName; - }; -} - - -function map(obj, iterator, context) { - var results = []; - forEach(obj, function(value, index, list) { - results.push(iterator.call(context, value, index, list)); - }); - return results; -} - - -/** - * @description - * Determines the number of elements in an array, the number of properties an object has, or - * the length of a string. - * - * Note: This function is used to augment the Object type in Angular expressions. See - * {@link angular.Object} for more information about Angular arrays. - * - * @param {Object|Array|string} obj Object, array, or string to inspect. - * @param {boolean} [ownPropsOnly=false] Count only "own" properties in an object - * @returns {number} The size of `obj` or `0` if `obj` is neither an object nor an array. - */ -function size(obj, ownPropsOnly) { - var size = 0, key; - - if (isArray(obj) || isString(obj)) { - return obj.length; - } else if (isObject(obj)){ - for (key in obj) - if (!ownPropsOnly || obj.hasOwnProperty(key)) - size++; - } - - return size; -} - - -function includes(array, obj) { - return indexOf(array, obj) != -1; -} - -function indexOf(array, obj) { - if (array.indexOf) return array.indexOf(obj); - - for ( var i = 0; i < array.length; i++) { - if (obj === array[i]) return i; - } - return -1; -} - -function arrayRemove(array, value) { - var index = indexOf(array, value); - if (index >=0) - array.splice(index, 1); - return value; -} - -function isLeafNode (node) { - if (node) { - switch (node.nodeName) { - case "OPTION": - case "PRE": - case "TITLE": - return true; - } - } - return false; -} - -/** - * @ngdoc function - * @name angular.copy - * @function - * - * @description - * Creates a deep copy of `source`, which should be an object or an array. - * - * * If no destination is supplied, a copy of the object or array is created. - * * If a destination is provided, all of its elements (for array) or properties (for objects) - * are deleted and then all elements/properties from the source are copied to it. - * * If `source` is not an object or array, `source` is returned. - * - * Note: this function is used to augment the Object type in Angular expressions. See - * {@link ng.$filter} for more information about Angular arrays. - * - * @param {*} source The source that will be used to make a copy. - * Can be any type, including primitives, `null`, and `undefined`. - * @param {(Object|Array)=} destination Destination into which the source is copied. If - * provided, must be of the same type as `source`. - * @returns {*} The copy or updated `destination`, if `destination` was specified. - */ -function copy(source, destination){ - if (isWindow(source) || isScope(source)) throw Error("Can't copy Window or Scope"); - if (!destination) { - destination = source; - if (source) { - if (isArray(source)) { - destination = copy(source, []); - } else if (isDate(source)) { - destination = new Date(source.getTime()); - } else if (isObject(source)) { - destination = copy(source, {}); - } - } - } else { - if (source === destination) throw Error("Can't copy equivalent objects or arrays"); - if (isArray(source)) { - destination.length = 0; - for ( var i = 0; i < source.length; i++) { - destination.push(copy(source[i])); - } - } else { - forEach(destination, function(value, key){ - delete destination[key]; - }); - for ( var key in source) { - destination[key] = copy(source[key]); - } - } - } - return destination; -} - -/** - * Create a shallow copy of an object - */ -function shallowCopy(src, dst) { - dst = dst || {}; - - for(var key in src) { - if (src.hasOwnProperty(key) && key.substr(0, 2) !== '$$') { - dst[key] = src[key]; - } - } - - return dst; -} - - -/** - * @ngdoc function - * @name angular.equals - * @function - * - * @description - * Determines if two objects or two values are equivalent. Supports value types, arrays and - * objects. - * - * Two objects or values are considered equivalent if at least one of the following is true: - * - * * Both objects or values pass `===` comparison. - * * Both objects or values are of the same type and all of their properties pass `===` comparison. - * * Both values are NaN. (In JavasScript, NaN == NaN => false. But we consider two NaN as equal) - * - * During a property comparision, properties of `function` type and properties with names - * that begin with `$` are ignored. - * - * Scope and DOMWindow objects are being compared only be identify (`===`). - * - * @param {*} o1 Object or value to compare. - * @param {*} o2 Object or value to compare. - * @returns {boolean} True if arguments are equal. - */ -function equals(o1, o2) { - if (o1 === o2) return true; - if (o1 === null || o2 === null) return false; - if (o1 !== o1 && o2 !== o2) return true; // NaN === NaN - var t1 = typeof o1, t2 = typeof o2, length, key, keySet; - if (t1 == t2) { - if (t1 == 'object') { - if (isArray(o1)) { - if ((length = o1.length) == o2.length) { - for(key=0; key 2 ? sliceArgs(arguments, 2) : []; - if (isFunction(fn) && !(fn instanceof RegExp)) { - return curryArgs.length - ? function() { - return arguments.length - ? fn.apply(self, curryArgs.concat(slice.call(arguments, 0))) - : fn.apply(self, curryArgs); - } - : function() { - return arguments.length - ? fn.apply(self, arguments) - : fn.call(self); - }; - } else { - // in IE, native methods are not functions so they cannot be bound (note: they don't need to be) - return fn; - } -} - - -function toJsonReplacer(key, value) { - var val = value; - - if (/^\$+/.test(key)) { - val = undefined; - } else if (isWindow(value)) { - val = '$WINDOW'; - } else if (value && document === value) { - val = '$DOCUMENT'; - } else if (isScope(value)) { - val = '$SCOPE'; - } - - return val; -} - - -/** - * @ngdoc function - * @name angular.toJson - * @function - * - * @description - * Serializes input into a JSON-formatted string. - * - * @param {Object|Array|Date|string|number} obj Input to be serialized into JSON. - * @param {boolean=} pretty If set to true, the JSON output will contain newlines and whitespace. - * @returns {string} Jsonified string representing `obj`. - */ -function toJson(obj, pretty) { - return JSON.stringify(obj, toJsonReplacer, pretty ? ' ' : null); -} - - -/** - * @ngdoc function - * @name angular.fromJson - * @function - * - * @description - * Deserializes a JSON string. - * - * @param {string} json JSON string to deserialize. - * @returns {Object|Array|Date|string|number} Deserialized thingy. - */ -function fromJson(json) { - return isString(json) - ? JSON.parse(json) - : json; -} - - -function toBoolean(value) { - if (value && value.length !== 0) { - var v = lowercase("" + value); - value = !(v == 'f' || v == '0' || v == 'false' || v == 'no' || v == 'n' || v == '[]'); - } else { - value = false; - } - return value; -} - -/** - * @returns {string} Returns the string representation of the element. - */ -function startingTag(element) { - element = jqLite(element).clone(); - try { - // turns out IE does not let you set .html() on elements which - // are not allowed to have children. So we just ignore it. - element.html(''); - } catch(e) {} - // As Per DOM Standards - var TEXT_NODE = 3; - var elemHtml = jqLite('').append(element).html(); - try { - return element[0].nodeType === TEXT_NODE ? lowercase(elemHtml) : - elemHtml. - match(/^(<[^>]+>)/)[1]. - replace(/^<([\w\-]+)/, function(match, nodeName) { return '<' + lowercase(nodeName); }); - } catch(e) { - return lowercase(elemHtml); - } - -} - - -///////////////////////////////////////////////// - -/** - * Parses an escaped url query string into key-value pairs. - * @returns Object.<(string|boolean)> - */ -function parseKeyValue(/**string*/keyValue) { - var obj = {}, key_value, key; - forEach((keyValue || "").split('&'), function(keyValue){ - if (keyValue) { - key_value = keyValue.split('='); - key = decodeURIComponent(key_value[0]); - obj[key] = isDefined(key_value[1]) ? decodeURIComponent(key_value[1]) : true; - } - }); - return obj; -} - -function toKeyValue(obj) { - var parts = []; - forEach(obj, function(value, key) { - parts.push(encodeUriQuery(key, true) + (value === true ? '' : '=' + encodeUriQuery(value, true))); - }); - return parts.length ? parts.join('&') : ''; -} - - -/** - * We need our custom method because encodeURIComponent is too agressive and doesn't follow - * http://www.ietf.org/rfc/rfc3986.txt with regards to the character set (pchar) allowed in path - * segments: - * segment = *pchar - * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" - * pct-encoded = "%" HEXDIG HEXDIG - * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" - * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" - * / "*" / "+" / "," / ";" / "=" - */ -function encodeUriSegment(val) { - return encodeUriQuery(val, true). - replace(/%26/gi, '&'). - replace(/%3D/gi, '='). - replace(/%2B/gi, '+'); -} - - -/** - * This method is intended for encoding *key* or *value* parts of query component. We need a custom - * method becuase encodeURIComponent is too agressive and encodes stuff that doesn't have to be - * encoded per http://tools.ietf.org/html/rfc3986: - * query = *( pchar / "/" / "?" ) - * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" - * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" - * pct-encoded = "%" HEXDIG HEXDIG - * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" - * / "*" / "+" / "," / ";" / "=" - */ -function encodeUriQuery(val, pctEncodeSpaces) { - return encodeURIComponent(val). - replace(/%40/gi, '@'). - replace(/%3A/gi, ':'). - replace(/%24/g, '$'). - replace(/%2C/gi, ','). - replace((pctEncodeSpaces ? null : /%20/g), '+'); -} - - -/** - * @ngdoc directive - * @name ng.directive:ngApp - * - * @element ANY - * @param {angular.Module} ngApp an optional application - * {@link angular.module module} name to load. - * - * @description - * - * Use this directive to auto-bootstrap on application. Only - * one directive can be used per HTML document. The directive - * designates the root of the application and is typically placed - * at the root of the page. - * - * In the example below if the `ngApp` directive would not be placed - * on the `html` element then the document would not be compiled - * and the `{{ 1+2 }}` would not be resolved to `3`. - * - * `ngApp` is the easiest way to bootstrap an application. - * - - - I can add: 1 + 2 = {{ 1+2 }} - - - * - */ -function angularInit(element, bootstrap) { - var elements = [element], - appElement, - module, - names = ['ng:app', 'ng-app', 'x-ng-app', 'data-ng-app'], - NG_APP_CLASS_REGEXP = /\sng[:\-]app(:\s*([\w\d_]+);?)?\s/; - - function append(element) { - element && elements.push(element); - } - - forEach(names, function(name) { - names[name] = true; - append(document.getElementById(name)); - name = name.replace(':', '\\:'); - if (element.querySelectorAll) { - forEach(element.querySelectorAll('.' + name), append); - forEach(element.querySelectorAll('.' + name + '\\:'), append); - forEach(element.querySelectorAll('[' + name + ']'), append); - } - }); - - forEach(elements, function(element) { - if (!appElement) { - var className = ' ' + element.className + ' '; - var match = NG_APP_CLASS_REGEXP.exec(className); - if (match) { - appElement = element; - module = (match[2] || '').replace(/\s+/g, ','); - } else { - forEach(element.attributes, function(attr) { - if (!appElement && names[attr.name]) { - appElement = element; - module = attr.value; - } - }); - } - } - }); - if (appElement) { - bootstrap(appElement, module ? [module] : []); - } -} - -/** - * @ngdoc function - * @name angular.bootstrap - * @description - * Use this function to manually start up angular application. - * - * See: {@link guide/bootstrap Bootstrap} - * - * @param {Element} element DOM element which is the root of angular application. - * @param {Array=} modules an array of module declarations. See: {@link angular.module modules} - * @returns {AUTO.$injector} Returns the newly created injector for this app. - */ -function bootstrap(element, modules) { - element = jqLite(element); - modules = modules || []; - modules.unshift(['$provide', function($provide) { - $provide.value('$rootElement', element); - }]); - modules.unshift('ng'); - var injector = createInjector(modules); - injector.invoke( - ['$rootScope', '$rootElement', '$compile', '$injector', function(scope, element, compile, injector){ - scope.$apply(function() { - element.data('$injector', injector); - compile(element)(scope); - }); - }] - ); - return injector; -} - -var SNAKE_CASE_REGEXP = /[A-Z]/g; -function snake_case(name, separator){ - separator = separator || '_'; - return name.replace(SNAKE_CASE_REGEXP, function(letter, pos) { - return (pos ? separator : '') + letter.toLowerCase(); - }); -} - -function bindJQuery() { - // bind to jQuery if present; - jQuery = window.jQuery; - // reset to jQuery or default to us. - if (jQuery) { - jqLite = jQuery; - extend(jQuery.fn, { - scope: JQLitePrototype.scope, - controller: JQLitePrototype.controller, - injector: JQLitePrototype.injector, - inheritedData: JQLitePrototype.inheritedData - }); - JQLitePatchJQueryRemove('remove', true); - JQLitePatchJQueryRemove('empty'); - JQLitePatchJQueryRemove('html'); - } else { - jqLite = JQLite; - } - angular.element = jqLite; -} - -/** - * throw error of the argument is falsy. - */ -function assertArg(arg, name, reason) { - if (!arg) { - throw new Error("Argument '" + (name || '?') + "' is " + (reason || "required")); - } - return arg; -} - -function assertArgFn(arg, name, acceptArrayAnnotation) { - if (acceptArrayAnnotation && isArray(arg)) { - arg = arg[arg.length - 1]; - } - - assertArg(isFunction(arg), name, 'not a function, got ' + - (arg && typeof arg == 'object' ? arg.constructor.name || 'Object' : typeof arg)); - return arg; -} - -/** - * @ngdoc interface - * @name angular.Module - * @description - * - * Interface for configuring angular {@link angular.module modules}. - */ - -function setupModuleLoader(window) { - - function ensure(obj, name, factory) { - return obj[name] || (obj[name] = factory()); - } - - return ensure(ensure(window, 'angular', Object), 'module', function() { - /** @type {Object.} */ - var modules = {}; - - /** - * @ngdoc function - * @name angular.module - * @description - * - * The `angular.module` is a global place for creating and registering Angular modules. All - * modules (angular core or 3rd party) that should be available to an application must be - * registered using this mechanism. - * - * - * # Module - * - * A module is a collocation of services, directives, filters, and configuration information. Module - * is used to configure the {@link AUTO.$injector $injector}. - * - * - * // Create a new module - * var myModule = angular.module('myModule', []); - * - * // register a new service - * myModule.value('appName', 'MyCoolApp'); - * - * // configure existing services inside initialization blocks. - * myModule.config(function($locationProvider) { - * // Configure existing providers - * $locationProvider.hashPrefix('!'); - * }); - * - * - * Then you can create an injector and load your modules like this: - * - * - * var injector = angular.injector(['ng', 'MyModule']) - * - * - * However it's more likely that you'll just use - * {@link ng.directive:ngApp ngApp} or - * {@link angular.bootstrap} to simplify this process for you. - * - * @param {!string} name The name of the module to create or retrieve. - * @param {Array.=} requires If specified then new module is being created. If unspecified then the - * the module is being retrieved for further configuration. - * @param {Function} configFn Optional configuration function for the module. Same as - * {@link angular.Module#config Module#config()}. - * @returns {module} new module with the {@link angular.Module} api. - */ - return function module(name, requires, configFn) { - if (requires && modules.hasOwnProperty(name)) { - modules[name] = null; - } - return ensure(modules, name, function() { - if (!requires) { - throw Error('No module: ' + name); - } - - /** @type {!Array.>} */ - var invokeQueue = []; - - /** @type {!Array.} */ - var runBlocks = []; - - var config = invokeLater('$injector', 'invoke'); - - /** @type {angular.Module} */ - var moduleInstance = { - // Private state - _invokeQueue: invokeQueue, - _runBlocks: runBlocks, - - /** - * @ngdoc property - * @name angular.Module#requires - * @propertyOf angular.Module - * @returns {Array.} List of module names which must be loaded before this module. - * @description - * Holds the list of modules which the injector will load before the current module is loaded. - */ - requires: requires, - - /** - * @ngdoc property - * @name angular.Module#name - * @propertyOf angular.Module - * @returns {string} Name of the module. - * @description - */ - name: name, - - - /** - * @ngdoc method - * @name angular.Module#provider - * @methodOf angular.Module - * @param {string} name service name - * @param {Function} providerType Construction function for creating new instance of the service. - * @description - * See {@link AUTO.$provide#provider $provide.provider()}. - */ - provider: invokeLater('$provide', 'provider'), - - /** - * @ngdoc method - * @name angular.Module#factory - * @methodOf angular.Module - * @param {string} name service name - * @param {Function} providerFunction Function for creating new instance of the service. - * @description - * See {@link AUTO.$provide#factory $provide.factory()}. - */ - factory: invokeLater('$provide', 'factory'), - - /** - * @ngdoc method - * @name angular.Module#service - * @methodOf angular.Module - * @param {string} name service name - * @param {Function} constructor A constructor function that will be instantiated. - * @description - * See {@link AUTO.$provide#service $provide.service()}. - */ - service: invokeLater('$provide', 'service'), - - /** - * @ngdoc method - * @name angular.Module#value - * @methodOf angular.Module - * @param {string} name service name - * @param {*} object Service instance object. - * @description - * See {@link AUTO.$provide#value $provide.value()}. - */ - value: invokeLater('$provide', 'value'), - - /** - * @ngdoc method - * @name angular.Module#constant - * @methodOf angular.Module - * @param {string} name constant name - * @param {*} object Constant value. - * @description - * Because the constant are fixed, they get applied before other provide methods. - * See {@link AUTO.$provide#constant $provide.constant()}. - */ - constant: invokeLater('$provide', 'constant', 'unshift'), - - /** - * @ngdoc method - * @name angular.Module#filter - * @methodOf angular.Module - * @param {string} name Filter name. - * @param {Function} filterFactory Factory function for creating new instance of filter. - * @description - * See {@link ng.$filterProvider#register $filterProvider.register()}. - */ - filter: invokeLater('$filterProvider', 'register'), - - /** - * @ngdoc method - * @name angular.Module#controller - * @methodOf angular.Module - * @param {string} name Controller name. - * @param {Function} constructor Controller constructor function. - * @description - * See {@link ng.$controllerProvider#register $controllerProvider.register()}. - */ - controller: invokeLater('$controllerProvider', 'register'), - - /** - * @ngdoc method - * @name angular.Module#directive - * @methodOf angular.Module - * @param {string} name directive name - * @param {Function} directiveFactory Factory function for creating new instance of - * directives. - * @description - * See {@link ng.$compileProvider#directive $compileProvider.directive()}. - */ - directive: invokeLater('$compileProvider', 'directive'), - - /** - * @ngdoc method - * @name angular.Module#config - * @methodOf angular.Module - * @param {Function} configFn Execute this function on module load. Useful for service - * configuration. - * @description - * Use this method to register work which needs to be performed on module loading. - */ - config: config, - - /** - * @ngdoc method - * @name angular.Module#run - * @methodOf angular.Module - * @param {Function} initializationFn Execute this function after injector creation. - * Useful for application initialization. - * @description - * Use this method to register work which should be performed when the injector is done - * loading all modules. - */ - run: function(block) { - runBlocks.push(block); - return this; - } - }; - - if (configFn) { - config(configFn); - } - - return moduleInstance; - - /** - * @param {string} provider - * @param {string} method - * @param {String=} insertMethod - * @returns {angular.Module} - */ - function invokeLater(provider, method, insertMethod) { - return function() { - invokeQueue[insertMethod || 'push']([provider, method, arguments]); - return moduleInstance; - } - } - }); - }; - }); - -} - -/** - * @ngdoc property - * @name angular.version - * @description - * An object that contains information about the current AngularJS version. This object has the - * following properties: - * - * - `full` – `{string}` – Full version string, such as "0.9.18". - * - `major` – `{number}` – Major version number, such as "0". - * - `minor` – `{number}` – Minor version number, such as "9". - * - `dot` – `{number}` – Dot version number, such as "18". - * - `codeName` – `{string}` – Code name of the release, such as "jiggling-armfat". - */ -var version = { - full: '1.0.5', // all of these placeholder strings will be replaced by rake's - major: 1, // compile task - minor: 0, - dot: 5, - codeName: 'flatulent-propulsion' -}; - - -function publishExternalAPI(angular){ - extend(angular, { - 'bootstrap': bootstrap, - 'copy': copy, - 'extend': extend, - 'equals': equals, - 'element': jqLite, - 'forEach': forEach, - 'injector': createInjector, - 'noop':noop, - 'bind':bind, - 'toJson': toJson, - 'fromJson': fromJson, - 'identity':identity, - 'isUndefined': isUndefined, - 'isDefined': isDefined, - 'isString': isString, - 'isFunction': isFunction, - 'isObject': isObject, - 'isNumber': isNumber, - 'isElement': isElement, - 'isArray': isArray, - 'version': version, - 'isDate': isDate, - 'lowercase': lowercase, - 'uppercase': uppercase, - 'callbacks': {counter: 0} - }); - - angularModule = setupModuleLoader(window); - try { - angularModule('ngLocale'); - } catch (e) { - angularModule('ngLocale', []).provider('$locale', $LocaleProvider); - } - - angularModule('ng', ['ngLocale'], ['$provide', - function ngModule($provide) { - $provide.provider('$compile', $CompileProvider). - directive({ - a: htmlAnchorDirective, - input: inputDirective, - textarea: inputDirective, - form: formDirective, - script: scriptDirective, - select: selectDirective, - style: styleDirective, - option: optionDirective, - ngBind: ngBindDirective, - ngBindHtmlUnsafe: ngBindHtmlUnsafeDirective, - ngBindTemplate: ngBindTemplateDirective, - ngClass: ngClassDirective, - ngClassEven: ngClassEvenDirective, - ngClassOdd: ngClassOddDirective, - ngCsp: ngCspDirective, - ngCloak: ngCloakDirective, - ngController: ngControllerDirective, - ngForm: ngFormDirective, - ngHide: ngHideDirective, - ngInclude: ngIncludeDirective, - ngInit: ngInitDirective, - ngNonBindable: ngNonBindableDirective, - ngPluralize: ngPluralizeDirective, - ngRepeat: ngRepeatDirective, - ngShow: ngShowDirective, - ngSubmit: ngSubmitDirective, - ngStyle: ngStyleDirective, - ngSwitch: ngSwitchDirective, - ngSwitchWhen: ngSwitchWhenDirective, - ngSwitchDefault: ngSwitchDefaultDirective, - ngOptions: ngOptionsDirective, - ngView: ngViewDirective, - ngTransclude: ngTranscludeDirective, - ngModel: ngModelDirective, - ngList: ngListDirective, - ngChange: ngChangeDirective, - required: requiredDirective, - ngRequired: requiredDirective, - ngValue: ngValueDirective - }). - directive(ngAttributeAliasDirectives). - directive(ngEventDirectives); - $provide.provider({ - $anchorScroll: $AnchorScrollProvider, - $browser: $BrowserProvider, - $cacheFactory: $CacheFactoryProvider, - $controller: $ControllerProvider, - $document: $DocumentProvider, - $exceptionHandler: $ExceptionHandlerProvider, - $filter: $FilterProvider, - $interpolate: $InterpolateProvider, - $http: $HttpProvider, - $httpBackend: $HttpBackendProvider, - $location: $LocationProvider, - $log: $LogProvider, - $parse: $ParseProvider, - $route: $RouteProvider, - $routeParams: $RouteParamsProvider, - $rootScope: $RootScopeProvider, - $q: $QProvider, - $sniffer: $SnifferProvider, - $templateCache: $TemplateCacheProvider, - $timeout: $TimeoutProvider, - $window: $WindowProvider - }); - } - ]); -} - -////////////////////////////////// -//JQLite -////////////////////////////////// - -/** - * @ngdoc function - * @name angular.element - * @function - * - * @description - * Wraps a raw DOM element or HTML string as a [jQuery](http://jquery.com) element. - * `angular.element` can be either an alias for [jQuery](http://api.jquery.com/jQuery/) function, if - * jQuery is available, or a function that wraps the element or string in Angular's jQuery lite - * implementation (commonly referred to as jqLite). - * - * Real jQuery always takes precedence over jqLite, provided it was loaded before `DOMContentLoaded` - * event fired. - * - * jqLite is a tiny, API-compatible subset of jQuery that allows - * Angular to manipulate the DOM. jqLite implements only the most commonly needed functionality - * within a very small footprint, so only a subset of the jQuery API - methods, arguments and - * invocation styles - are supported. - * - * Note: All element references in Angular are always wrapped with jQuery or jqLite; they are never - * raw DOM references. - * - * ## Angular's jQuery lite provides the following methods: - * - * - [addClass()](http://api.jquery.com/addClass/) - * - [after()](http://api.jquery.com/after/) - * - [append()](http://api.jquery.com/append/) - * - [attr()](http://api.jquery.com/attr/) - * - [bind()](http://api.jquery.com/bind/) - * - [children()](http://api.jquery.com/children/) - * - [clone()](http://api.jquery.com/clone/) - * - [contents()](http://api.jquery.com/contents/) - * - [css()](http://api.jquery.com/css/) - * - [data()](http://api.jquery.com/data/) - * - [eq()](http://api.jquery.com/eq/) - * - [find()](http://api.jquery.com/find/) - Limited to lookups by tag name. - * - [hasClass()](http://api.jquery.com/hasClass/) - * - [html()](http://api.jquery.com/html/) - * - [next()](http://api.jquery.com/next/) - * - [parent()](http://api.jquery.com/parent/) - * - [prepend()](http://api.jquery.com/prepend/) - * - [prop()](http://api.jquery.com/prop/) - * - [ready()](http://api.jquery.com/ready/) - * - [remove()](http://api.jquery.com/remove/) - * - [removeAttr()](http://api.jquery.com/removeAttr/) - * - [removeClass()](http://api.jquery.com/removeClass/) - * - [removeData()](http://api.jquery.com/removeData/) - * - [replaceWith()](http://api.jquery.com/replaceWith/) - * - [text()](http://api.jquery.com/text/) - * - [toggleClass()](http://api.jquery.com/toggleClass/) - * - [triggerHandler()](http://api.jquery.com/triggerHandler/) - Doesn't pass native event objects to handlers. - * - [unbind()](http://api.jquery.com/unbind/) - * - [val()](http://api.jquery.com/val/) - * - [wrap()](http://api.jquery.com/wrap/) - * - * ## In addtion to the above, Angular provides additional methods to both jQuery and jQuery lite: - * - * - `controller(name)` - retrieves the controller of the current element or its parent. By default - * retrieves controller associated with the `ngController` directive. If `name` is provided as - * camelCase directive name, then the controller for this directive will be retrieved (e.g. - * `'ngModel'`). - * - `injector()` - retrieves the injector of the current element or its parent. - * - `scope()` - retrieves the {@link api/ng.$rootScope.Scope scope} of the current - * element or its parent. - * - `inheritedData()` - same as `data()`, but walks up the DOM until a value is found or the top - * parent element is reached. - * - * @param {string|DOMElement} element HTML string or DOMElement to be wrapped into jQuery. - * @returns {Object} jQuery object. - */ - -var jqCache = JQLite.cache = {}, - jqName = JQLite.expando = 'ng-' + new Date().getTime(), - jqId = 1, - addEventListenerFn = (window.document.addEventListener - ? function(element, type, fn) {element.addEventListener(type, fn, false);} - : function(element, type, fn) {element.attachEvent('on' + type, fn);}), - removeEventListenerFn = (window.document.removeEventListener - ? function(element, type, fn) {element.removeEventListener(type, fn, false); } - : function(element, type, fn) {element.detachEvent('on' + type, fn); }); - -function jqNextId() { return ++jqId; } - - -var SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g; -var MOZ_HACK_REGEXP = /^moz([A-Z])/; - -/** - * Converts snake_case to camelCase. - * Also there is special case for Moz prefix starting with upper case letter. - * @param name Name to normalize - */ -function camelCase(name) { - return name. - replace(SPECIAL_CHARS_REGEXP, function(_, separator, letter, offset) { - return offset ? letter.toUpperCase() : letter; - }). - replace(MOZ_HACK_REGEXP, 'Moz$1'); -} - -///////////////////////////////////////////// -// jQuery mutation patch -// -// In conjunction with bindJQuery intercepts all jQuery's DOM destruction apis and fires a -// $destroy event on all DOM nodes being removed. -// -///////////////////////////////////////////// - -function JQLitePatchJQueryRemove(name, dispatchThis) { - var originalJqFn = jQuery.fn[name]; - originalJqFn = originalJqFn.$original || originalJqFn; - removePatch.$original = originalJqFn; - jQuery.fn[name] = removePatch; - - function removePatch() { - var list = [this], - fireEvent = dispatchThis, - set, setIndex, setLength, - element, childIndex, childLength, children, - fns, events; - - while(list.length) { - set = list.shift(); - for(setIndex = 0, setLength = set.length; setIndex < setLength; setIndex++) { - element = jqLite(set[setIndex]); - if (fireEvent) { - element.triggerHandler('$destroy'); - } else { - fireEvent = !fireEvent; - } - for(childIndex = 0, childLength = (children = element.children()).length; - childIndex < childLength; - childIndex++) { - list.push(jQuery(children[childIndex])); - } - } - } - return originalJqFn.apply(this, arguments); - } -} - -///////////////////////////////////////////// -function JQLite(element) { - if (element instanceof JQLite) { - return element; - } - if (!(this instanceof JQLite)) { - if (isString(element) && element.charAt(0) != '<') { - throw Error('selectors not implemented'); - } - return new JQLite(element); - } - - if (isString(element)) { - var div = document.createElement('div'); - // Read about the NoScope elements here: - // http://msdn.microsoft.com/en-us/library/ms533897(VS.85).aspx - div.innerHTML = ' ' + element; // IE insanity to make NoScope elements work! - div.removeChild(div.firstChild); // remove the superfluous div - JQLiteAddNodes(this, div.childNodes); - this.remove(); // detach the elements from the temporary DOM div. - } else { - JQLiteAddNodes(this, element); - } -} - -function JQLiteClone(element) { - return element.cloneNode(true); -} - -function JQLiteDealoc(element){ - JQLiteRemoveData(element); - for ( var i = 0, children = element.childNodes || []; i < children.length; i++) { - JQLiteDealoc(children[i]); - } -} - -function JQLiteUnbind(element, type, fn) { - var events = JQLiteExpandoStore(element, 'events'), - handle = JQLiteExpandoStore(element, 'handle'); - - if (!handle) return; //no listeners registered - - if (isUndefined(type)) { - forEach(events, function(eventHandler, type) { - removeEventListenerFn(element, type, eventHandler); - delete events[type]; - }); - } else { - if (isUndefined(fn)) { - removeEventListenerFn(element, type, events[type]); - delete events[type]; - } else { - arrayRemove(events[type], fn); - } - } -} - -function JQLiteRemoveData(element) { - var expandoId = element[jqName], - expandoStore = jqCache[expandoId]; - - if (expandoStore) { - if (expandoStore.handle) { - expandoStore.events.$destroy && expandoStore.handle({}, '$destroy'); - JQLiteUnbind(element); - } - delete jqCache[expandoId]; - element[jqName] = undefined; // ie does not allow deletion of attributes on elements. - } -} - -function JQLiteExpandoStore(element, key, value) { - var expandoId = element[jqName], - expandoStore = jqCache[expandoId || -1]; - - if (isDefined(value)) { - if (!expandoStore) { - element[jqName] = expandoId = jqNextId(); - expandoStore = jqCache[expandoId] = {}; - } - expandoStore[key] = value; - } else { - return expandoStore && expandoStore[key]; - } -} - -function JQLiteData(element, key, value) { - var data = JQLiteExpandoStore(element, 'data'), - isSetter = isDefined(value), - keyDefined = !isSetter && isDefined(key), - isSimpleGetter = keyDefined && !isObject(key); - - if (!data && !isSimpleGetter) { - JQLiteExpandoStore(element, 'data', data = {}); - } - - if (isSetter) { - data[key] = value; - } else { - if (keyDefined) { - if (isSimpleGetter) { - // don't create data in this case. - return data && data[key]; - } else { - extend(data, key); - } - } else { - return data; - } - } -} - -function JQLiteHasClass(element, selector) { - return ((" " + element.className + " ").replace(/[\n\t]/g, " "). - indexOf( " " + selector + " " ) > -1); -} - -function JQLiteRemoveClass(element, cssClasses) { - if (cssClasses) { - forEach(cssClasses.split(' '), function(cssClass) { - element.className = trim( - (" " + element.className + " ") - .replace(/[\n\t]/g, " ") - .replace(" " + trim(cssClass) + " ", " ") - ); - }); - } -} - -function JQLiteAddClass(element, cssClasses) { - if (cssClasses) { - forEach(cssClasses.split(' '), function(cssClass) { - if (!JQLiteHasClass(element, cssClass)) { - element.className = trim(element.className + ' ' + trim(cssClass)); - } - }); - } -} - -function JQLiteAddNodes(root, elements) { - if (elements) { - elements = (!elements.nodeName && isDefined(elements.length) && !isWindow(elements)) - ? elements - : [ elements ]; - for(var i=0; i < elements.length; i++) { - root.push(elements[i]); - } - } -} - -function JQLiteController(element, name) { - return JQLiteInheritedData(element, '$' + (name || 'ngController' ) + 'Controller'); -} - -function JQLiteInheritedData(element, name, value) { - element = jqLite(element); - - // if element is the document object work with the html element instead - // this makes $(document).scope() possible - if(element[0].nodeType == 9) { - element = element.find('html'); - } - - while (element.length) { - if (value = element.data(name)) return value; - element = element.parent(); - } -} - -////////////////////////////////////////// -// Functions which are declared directly. -////////////////////////////////////////// -var JQLitePrototype = JQLite.prototype = { - ready: function(fn) { - var fired = false; - - function trigger() { - if (fired) return; - fired = true; - fn(); - } - - this.bind('DOMContentLoaded', trigger); // works for modern browsers and IE9 - // we can not use jqLite since we are not done loading and jQuery could be loaded later. - JQLite(window).bind('load', trigger); // fallback to window.onload for others - }, - toString: function() { - var value = []; - forEach(this, function(e){ value.push('' + e);}); - return '[' + value.join(', ') + ']'; - }, - - eq: function(index) { - return (index >= 0) ? jqLite(this[index]) : jqLite(this[this.length + index]); - }, - - length: 0, - push: push, - sort: [].sort, - splice: [].splice -}; - -////////////////////////////////////////// -// Functions iterating getter/setters. -// these functions return self on setter and -// value on get. -////////////////////////////////////////// -var BOOLEAN_ATTR = {}; -forEach('multiple,selected,checked,disabled,readOnly,required'.split(','), function(value) { - BOOLEAN_ATTR[lowercase(value)] = value; -}); -var BOOLEAN_ELEMENTS = {}; -forEach('input,select,option,textarea,button,form'.split(','), function(value) { - BOOLEAN_ELEMENTS[uppercase(value)] = true; -}); - -function getBooleanAttrName(element, name) { - // check dom last since we will most likely fail on name - var booleanAttr = BOOLEAN_ATTR[name.toLowerCase()]; - - // booleanAttr is here twice to minimize DOM access - return booleanAttr && BOOLEAN_ELEMENTS[element.nodeName] && booleanAttr; -} - -forEach({ - data: JQLiteData, - inheritedData: JQLiteInheritedData, - - scope: function(element) { - return JQLiteInheritedData(element, '$scope'); - }, - - controller: JQLiteController , - - injector: function(element) { - return JQLiteInheritedData(element, '$injector'); - }, - - removeAttr: function(element,name) { - element.removeAttribute(name); - }, - - hasClass: JQLiteHasClass, - - css: function(element, name, value) { - name = camelCase(name); - - if (isDefined(value)) { - element.style[name] = value; - } else { - var val; - - if (msie <= 8) { - // this is some IE specific weirdness that jQuery 1.6.4 does not sure why - val = element.currentStyle && element.currentStyle[name]; - if (val === '') val = 'auto'; - } - - val = val || element.style[name]; - - if (msie <= 8) { - // jquery weirdness :-/ - val = (val === '') ? undefined : val; - } - - return val; - } - }, - - attr: function(element, name, value){ - var lowercasedName = lowercase(name); - if (BOOLEAN_ATTR[lowercasedName]) { - if (isDefined(value)) { - if (!!value) { - element[name] = true; - element.setAttribute(name, lowercasedName); - } else { - element[name] = false; - element.removeAttribute(lowercasedName); - } - } else { - return (element[name] || - (element.attributes.getNamedItem(name)|| noop).specified) - ? lowercasedName - : undefined; - } - } else if (isDefined(value)) { - element.setAttribute(name, value); - } else if (element.getAttribute) { - // the extra argument "2" is to get the right thing for a.href in IE, see jQuery code - // some elements (e.g. Document) don't have get attribute, so return undefined - var ret = element.getAttribute(name, 2); - // normalize non-existing attributes to undefined (as jQuery) - return ret === null ? undefined : ret; - } - }, - - prop: function(element, name, value) { - if (isDefined(value)) { - element[name] = value; - } else { - return element[name]; - } - }, - - text: extend((msie < 9) - ? function(element, value) { - if (element.nodeType == 1 /** Element */) { - if (isUndefined(value)) - return element.innerText; - element.innerText = value; - } else { - if (isUndefined(value)) - return element.nodeValue; - element.nodeValue = value; - } - } - : function(element, value) { - if (isUndefined(value)) { - return element.textContent; - } - element.textContent = value; - }, {$dv:''}), - - val: function(element, value) { - if (isUndefined(value)) { - return element.value; - } - element.value = value; - }, - - html: function(element, value) { - if (isUndefined(value)) { - return element.innerHTML; - } - for (var i = 0, childNodes = element.childNodes; i < childNodes.length; i++) { - JQLiteDealoc(childNodes[i]); - } - element.innerHTML = value; - } -}, function(fn, name){ - /** - * Properties: writes return selection, reads return first value - */ - JQLite.prototype[name] = function(arg1, arg2) { - var i, key; - - // JQLiteHasClass has only two arguments, but is a getter-only fn, so we need to special-case it - // in a way that survives minification. - if (((fn.length == 2 && (fn !== JQLiteHasClass && fn !== JQLiteController)) ? arg1 : arg2) === undefined) { - if (isObject(arg1)) { - - // we are a write, but the object properties are the key/values - for(i=0; i < this.length; i++) { - if (fn === JQLiteData) { - // data() takes the whole object in jQuery - fn(this[i], arg1); - } else { - for (key in arg1) { - fn(this[i], key, arg1[key]); - } - } - } - // return self for chaining - return this; - } else { - // we are a read, so read the first child. - if (this.length) - return fn(this[0], arg1, arg2); - } - } else { - // we are a write, so apply to all children - for(i=0; i < this.length; i++) { - fn(this[i], arg1, arg2); - } - // return self for chaining - return this; - } - return fn.$dv; - }; -}); - -function createEventHandler(element, events) { - var eventHandler = function (event, type) { - if (!event.preventDefault) { - event.preventDefault = function() { - event.returnValue = false; //ie - }; - } - - if (!event.stopPropagation) { - event.stopPropagation = function() { - event.cancelBubble = true; //ie - }; - } - - if (!event.target) { - event.target = event.srcElement || document; - } - - if (isUndefined(event.defaultPrevented)) { - var prevent = event.preventDefault; - event.preventDefault = function() { - event.defaultPrevented = true; - prevent.call(event); - }; - event.defaultPrevented = false; - } - - event.isDefaultPrevented = function() { - return event.defaultPrevented; - }; - - forEach(events[type || event.type], function(fn) { - fn.call(element, event); - }); - - // Remove monkey-patched methods (IE), - // as they would cause memory leaks in IE8. - if (msie <= 8) { - // IE7/8 does not allow to delete property on native object - event.preventDefault = null; - event.stopPropagation = null; - event.isDefaultPrevented = null; - } else { - // It shouldn't affect normal browsers (native methods are defined on prototype). - delete event.preventDefault; - delete event.stopPropagation; - delete event.isDefaultPrevented; - } - }; - eventHandler.elem = element; - return eventHandler; -} - -////////////////////////////////////////// -// Functions iterating traversal. -// These functions chain results into a single -// selector. -////////////////////////////////////////// -forEach({ - removeData: JQLiteRemoveData, - - dealoc: JQLiteDealoc, - - bind: function bindFn(element, type, fn){ - var events = JQLiteExpandoStore(element, 'events'), - handle = JQLiteExpandoStore(element, 'handle'); - - if (!events) JQLiteExpandoStore(element, 'events', events = {}); - if (!handle) JQLiteExpandoStore(element, 'handle', handle = createEventHandler(element, events)); - - forEach(type.split(' '), function(type){ - var eventFns = events[type]; - - if (!eventFns) { - if (type == 'mouseenter' || type == 'mouseleave') { - var counter = 0; - - events.mouseenter = []; - events.mouseleave = []; - - bindFn(element, 'mouseover', function(event) { - counter++; - if (counter == 1) { - handle(event, 'mouseenter'); - } - }); - bindFn(element, 'mouseout', function(event) { - counter --; - if (counter == 0) { - handle(event, 'mouseleave'); - } - }); - } else { - addEventListenerFn(element, type, handle); - events[type] = []; - } - eventFns = events[type] - } - eventFns.push(fn); - }); - }, - - unbind: JQLiteUnbind, - - replaceWith: function(element, replaceNode) { - var index, parent = element.parentNode; - JQLiteDealoc(element); - forEach(new JQLite(replaceNode), function(node){ - if (index) { - parent.insertBefore(node, index.nextSibling); - } else { - parent.replaceChild(node, element); - } - index = node; - }); - }, - - children: function(element) { - var children = []; - forEach(element.childNodes, function(element){ - if (element.nodeType === 1) - children.push(element); - }); - return children; - }, - - contents: function(element) { - return element.childNodes || []; - }, - - append: function(element, node) { - forEach(new JQLite(node), function(child){ - if (element.nodeType === 1) - element.appendChild(child); - }); - }, - - prepend: function(element, node) { - if (element.nodeType === 1) { - var index = element.firstChild; - forEach(new JQLite(node), function(child){ - if (index) { - element.insertBefore(child, index); - } else { - element.appendChild(child); - index = child; - } - }); - } - }, - - wrap: function(element, wrapNode) { - wrapNode = jqLite(wrapNode)[0]; - var parent = element.parentNode; - if (parent) { - parent.replaceChild(wrapNode, element); - } - wrapNode.appendChild(element); - }, - - remove: function(element) { - JQLiteDealoc(element); - var parent = element.parentNode; - if (parent) parent.removeChild(element); - }, - - after: function(element, newElement) { - var index = element, parent = element.parentNode; - forEach(new JQLite(newElement), function(node){ - parent.insertBefore(node, index.nextSibling); - index = node; - }); - }, - - addClass: JQLiteAddClass, - removeClass: JQLiteRemoveClass, - - toggleClass: function(element, selector, condition) { - if (isUndefined(condition)) { - condition = !JQLiteHasClass(element, selector); - } - (condition ? JQLiteAddClass : JQLiteRemoveClass)(element, selector); - }, - - parent: function(element) { - var parent = element.parentNode; - return parent && parent.nodeType !== 11 ? parent : null; - }, - - next: function(element) { - if (element.nextElementSibling) { - return element.nextElementSibling; - } - - // IE8 doesn't have nextElementSibling - var elm = element.nextSibling; - while (elm != null && elm.nodeType !== 1) { - elm = elm.nextSibling; - } - return elm; - }, - - find: function(element, selector) { - return element.getElementsByTagName(selector); - }, - - clone: JQLiteClone, - - triggerHandler: function(element, eventName) { - var eventFns = (JQLiteExpandoStore(element, 'events') || {})[eventName]; - - forEach(eventFns, function(fn) { - fn.call(element, null); - }); - } -}, function(fn, name){ - /** - * chaining functions - */ - JQLite.prototype[name] = function(arg1, arg2) { - var value; - for(var i=0; i < this.length; i++) { - if (value == undefined) { - value = fn(this[i], arg1, arg2); - if (value !== undefined) { - // any function which returns a value needs to be wrapped - value = jqLite(value); - } - } else { - JQLiteAddNodes(value, fn(this[i], arg1, arg2)); - } - } - return value == undefined ? this : value; - }; -}); - -/** - * Computes a hash of an 'obj'. - * Hash of a: - * string is string - * number is number as string - * object is either result of calling $$hashKey function on the object or uniquely generated id, - * that is also assigned to the $$hashKey property of the object. - * - * @param obj - * @returns {string} hash string such that the same input will have the same hash string. - * The resulting string key is in 'type:hashKey' format. - */ -function hashKey(obj) { - var objType = typeof obj, - key; - - if (objType == 'object' && obj !== null) { - if (typeof (key = obj.$$hashKey) == 'function') { - // must invoke on object to keep the right this - key = obj.$$hashKey(); - } else if (key === undefined) { - key = obj.$$hashKey = nextUid(); - } - } else { - key = obj; - } - - return objType + ':' + key; -} - -/** - * HashMap which can use objects as keys - */ -function HashMap(array){ - forEach(array, this.put, this); -} -HashMap.prototype = { - /** - * Store key value pair - * @param key key to store can be any type - * @param value value to store can be any type - */ - put: function(key, value) { - this[hashKey(key)] = value; - }, - - /** - * @param key - * @returns the value for the key - */ - get: function(key) { - return this[hashKey(key)]; - }, - - /** - * Remove the key/value pair - * @param key - */ - remove: function(key) { - var value = this[key = hashKey(key)]; - delete this[key]; - return value; - } -}; - -/** - * A map where multiple values can be added to the same key such that they form a queue. - * @returns {HashQueueMap} - */ -function HashQueueMap() {} -HashQueueMap.prototype = { - /** - * Same as array push, but using an array as the value for the hash - */ - push: function(key, value) { - var array = this[key = hashKey(key)]; - if (!array) { - this[key] = [value]; - } else { - array.push(value); - } - }, - - /** - * Same as array shift, but using an array as the value for the hash - */ - shift: function(key) { - var array = this[key = hashKey(key)]; - if (array) { - if (array.length == 1) { - delete this[key]; - return array[0]; - } else { - return array.shift(); - } - } - }, - - /** - * return the first item without deleting it - */ - peek: function(key) { - var array = this[hashKey(key)]; - if (array) { - return array[0]; - } - } -}; - -/** - * @ngdoc function - * @name angular.injector - * @function - * - * @description - * Creates an injector function that can be used for retrieving services as well as for - * dependency injection (see {@link guide/di dependency injection}). - * - - * @param {Array.} modules A list of module functions or their aliases. See - * {@link angular.module}. The `ng` module must be explicitly added. - * @returns {function()} Injector function. See {@link AUTO.$injector $injector}. - * - * @example - * Typical usage - * - * // create an injector - * var $injector = angular.injector(['ng']); - * - * // use the injector to kick off your application - * // use the type inference to auto inject arguments, or use implicit injection - * $injector.invoke(function($rootScope, $compile, $document){ - * $compile($document)($rootScope); - * $rootScope.$digest(); - * }); - * - */ - - -/** - * @ngdoc overview - * @name AUTO - * @description - * - * Implicit module which gets automatically added to each {@link AUTO.$injector $injector}. - */ - -var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; -var FN_ARG_SPLIT = /,/; -var FN_ARG = /^\s*(_?)(\S+?)\1\s*$/; -var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; -function annotate(fn) { - var $inject, - fnText, - argDecl, - last; - - if (typeof fn == 'function') { - if (!($inject = fn.$inject)) { - $inject = []; - fnText = fn.toString().replace(STRIP_COMMENTS, ''); - argDecl = fnText.match(FN_ARGS); - forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg){ - arg.replace(FN_ARG, function(all, underscore, name){ - $inject.push(name); - }); - }); - fn.$inject = $inject; - } - } else if (isArray(fn)) { - last = fn.length - 1; - assertArgFn(fn[last], 'fn') - $inject = fn.slice(0, last); - } else { - assertArgFn(fn, 'fn', true); - } - return $inject; -} - -/////////////////////////////////////// - -/** - * @ngdoc object - * @name AUTO.$injector - * @function - * - * @description - * - * `$injector` is used to retrieve object instances as defined by - * {@link AUTO.$provide provider}, instantiate types, invoke methods, - * and load modules. - * - * The following always holds true: - * - * - * var $injector = angular.injector(); - * expect($injector.get('$injector')).toBe($injector); - * expect($injector.invoke(function($injector){ - * return $injector; - * }).toBe($injector); - * - * - * # Injection Function Annotation - * - * JavaScript does not have annotations, and annotations are needed for dependency injection. The - * following ways are all valid way of annotating function with injection arguments and are equivalent. - * - * - * // inferred (only works if code not minified/obfuscated) - * $inject.invoke(function(serviceA){}); - * - * // annotated - * function explicit(serviceA) {}; - * explicit.$inject = ['serviceA']; - * $inject.invoke(explicit); - * - * // inline - * $inject.invoke(['serviceA', function(serviceA){}]); - * - * - * ## Inference - * - * In JavaScript calling `toString()` on a function returns the function definition. The definition can then be - * parsed and the function arguments can be extracted. *NOTE:* This does not work with minification, and obfuscation - * tools since these tools change the argument names. - * - * ## `$inject` Annotation - * By adding a `$inject` property onto a function the injection parameters can be specified. - * - * ## Inline - * As an array of injection names, where the last item in the array is the function to call. - */ - -/** - * @ngdoc method - * @name AUTO.$injector#get - * @methodOf AUTO.$injector - * - * @description - * Return an instance of the service. - * - * @param {string} name The name of the instance to retrieve. - * @return {*} The instance. - */ - -/** - * @ngdoc method - * @name AUTO.$injector#invoke - * @methodOf AUTO.$injector - * - * @description - * Invoke the method and supply the method arguments from the `$injector`. - * - * @param {!function} fn The function to invoke. The function arguments come form the function annotation. - * @param {Object=} self The `this` for the invoked method. - * @param {Object=} locals Optional object. If preset then any argument names are read from this object first, before - * the `$injector` is consulted. - * @returns {*} the value returned by the invoked `fn` function. - */ - -/** - * @ngdoc method - * @name AUTO.$injector#instantiate - * @methodOf AUTO.$injector - * @description - * Create a new instance of JS type. The method takes a constructor function invokes the new operator and supplies - * all of the arguments to the constructor function as specified by the constructor annotation. - * - * @param {function} Type Annotated constructor function. - * @param {Object=} locals Optional object. If preset then any argument names are read from this object first, before - * the `$injector` is consulted. - * @returns {Object} new instance of `Type`. - */ - -/** - * @ngdoc method - * @name AUTO.$injector#annotate - * @methodOf AUTO.$injector - * - * @description - * Returns an array of service names which the function is requesting for injection. This API is used by the injector - * to determine which services need to be injected into the function when the function is invoked. There are three - * ways in which the function can be annotated with the needed dependencies. - * - * # Argument names - * - * The simplest form is to extract the dependencies from the arguments of the function. This is done by converting - * the function into a string using `toString()` method and extracting the argument names. - * - * // Given - * function MyController($scope, $route) { - * // ... - * } - * - * // Then - * expect(injector.annotate(MyController)).toEqual(['$scope', '$route']); - * - * - * This method does not work with code minfication / obfuscation. For this reason the following annotation strategies - * are supported. - * - * # The `$inject` property - * - * If a function has an `$inject` property and its value is an array of strings, then the strings represent names of - * services to be injected into the function. - * - * // Given - * var MyController = function(obfuscatedScope, obfuscatedRoute) { - * // ... - * } - * // Define function dependencies - * MyController.$inject = ['$scope', '$route']; - * - * // Then - * expect(injector.annotate(MyController)).toEqual(['$scope', '$route']); - * - * - * # The array notation - * - * It is often desirable to inline Injected functions and that's when setting the `$inject` property is very - * inconvenient. In these situations using the array notation to specify the dependencies in a way that survives - * minification is a better choice: - * - * - * // We wish to write this (not minification / obfuscation safe) - * injector.invoke(function($compile, $rootScope) { - * // ... - * }); - * - * // We are forced to write break inlining - * var tmpFn = function(obfuscatedCompile, obfuscatedRootScope) { - * // ... - * }; - * tmpFn.$inject = ['$compile', '$rootScope']; - * injector.invoke(tempFn); - * - * // To better support inline function the inline annotation is supported - * injector.invoke(['$compile', '$rootScope', function(obfCompile, obfRootScope) { - * // ... - * }]); - * - * // Therefore - * expect(injector.annotate( - * ['$compile', '$rootScope', function(obfus_$compile, obfus_$rootScope) {}]) - * ).toEqual(['$compile', '$rootScope']); - * - * - * @param {function|Array.} fn Function for which dependent service names need to be retrieved as described - * above. - * - * @returns {Array.} The names of the services which the function requires. - */ - - - - -/** - * @ngdoc object - * @name AUTO.$provide - * - * @description - * - * Use `$provide` to register new providers with the `$injector`. The providers are the factories for the instance. - * The providers share the same name as the instance they create with the `Provider` suffixed to them. - * - * A provider is an object with a `$get()` method. The injector calls the `$get` method to create a new instance of - * a service. The Provider can have additional methods which would allow for configuration of the provider. - * - * - * function GreetProvider() { - * var salutation = 'Hello'; - * - * this.salutation = function(text) { - * salutation = text; - * }; - * - * this.$get = function() { - * return function (name) { - * return salutation + ' ' + name + '!'; - * }; - * }; - * } - * - * describe('Greeter', function(){ - * - * beforeEach(module(function($provide) { - * $provide.provider('greet', GreetProvider); - * }); - * - * it('should greet', inject(function(greet) { - * expect(greet('angular')).toEqual('Hello angular!'); - * })); - * - * it('should allow configuration of salutation', function() { - * module(function(greetProvider) { - * greetProvider.salutation('Ahoj'); - * }); - * inject(function(greet) { - * expect(greet('angular')).toEqual('Ahoj angular!'); - * }); - * )}; - * - * }); - * - */ - -/** - * @ngdoc method - * @name AUTO.$provide#provider - * @methodOf AUTO.$provide - * @description - * - * Register a provider for a service. The providers can be retrieved and can have additional configuration methods. - * - * @param {string} name The name of the instance. NOTE: the provider will be available under `name + 'Provider'` key. - * @param {(Object|function())} provider If the provider is: - * - * - `Object`: then it should have a `$get` method. The `$get` method will be invoked using - * {@link AUTO.$injector#invoke $injector.invoke()} when an instance needs to be created. - * - `Constructor`: a new instance of the provider will be created using - * {@link AUTO.$injector#instantiate $injector.instantiate()}, then treated as `object`. - * - * @returns {Object} registered provider instance - */ - -/** - * @ngdoc method - * @name AUTO.$provide#factory - * @methodOf AUTO.$provide - * @description - * - * A short hand for configuring services if only `$get` method is required. - * - * @param {string} name The name of the instance. - * @param {function()} $getFn The $getFn for the instance creation. Internally this is a short hand for - * `$provide.provider(name, {$get: $getFn})`. - * @returns {Object} registered provider instance - */ - - -/** - * @ngdoc method - * @name AUTO.$provide#service - * @methodOf AUTO.$provide - * @description - * - * A short hand for registering service of given class. - * - * @param {string} name The name of the instance. - * @param {Function} constructor A class (constructor function) that will be instantiated. - * @returns {Object} registered provider instance - */ - - -/** - * @ngdoc method - * @name AUTO.$provide#value - * @methodOf AUTO.$provide - * @description - * - * A short hand for configuring services if the `$get` method is a constant. - * - * @param {string} name The name of the instance. - * @param {*} value The value. - * @returns {Object} registered provider instance - */ - - -/** - * @ngdoc method - * @name AUTO.$provide#constant - * @methodOf AUTO.$provide - * @description - * - * A constant value, but unlike {@link AUTO.$provide#value value} it can be injected - * into configuration function (other modules) and it is not interceptable by - * {@link AUTO.$provide#decorator decorator}. - * - * @param {string} name The name of the constant. - * @param {*} value The constant value. - * @returns {Object} registered instance - */ - - -/** - * @ngdoc method - * @name AUTO.$provide#decorator - * @methodOf AUTO.$provide - * @description - * - * Decoration of service, allows the decorator to intercept the service instance creation. The - * returned instance may be the original instance, or a new instance which delegates to the - * original instance. - * - * @param {string} name The name of the service to decorate. - * @param {function()} decorator This function will be invoked when the service needs to be - * instanciated. The function is called using the {@link AUTO.$injector#invoke - * injector.invoke} method and is therefore fully injectable. Local injection arguments: - * - * * `$delegate` - The original service instance, which can be monkey patched, configured, - * decorated or delegated to. - */ - - -function createInjector(modulesToLoad) { - var INSTANTIATING = {}, - providerSuffix = 'Provider', - path = [], - loadedModules = new HashMap(), - providerCache = { - $provide: { - provider: supportObject(provider), - factory: supportObject(factory), - service: supportObject(service), - value: supportObject(value), - constant: supportObject(constant), - decorator: decorator - } - }, - providerInjector = createInternalInjector(providerCache, function() { - throw Error("Unknown provider: " + path.join(' <- ')); - }), - instanceCache = {}, - instanceInjector = (instanceCache.$injector = - createInternalInjector(instanceCache, function(servicename) { - var provider = providerInjector.get(servicename + providerSuffix); - return instanceInjector.invoke(provider.$get, provider); - })); - - - forEach(loadModules(modulesToLoad), function(fn) { instanceInjector.invoke(fn || noop); }); - - return instanceInjector; - - //////////////////////////////////// - // $provider - //////////////////////////////////// - - function supportObject(delegate) { - return function(key, value) { - if (isObject(key)) { - forEach(key, reverseParams(delegate)); - } else { - return delegate(key, value); - } - } - } - - function provider(name, provider_) { - if (isFunction(provider_) || isArray(provider_)) { - provider_ = providerInjector.instantiate(provider_); - } - if (!provider_.$get) { - throw Error('Provider ' + name + ' must define $get factory method.'); - } - return providerCache[name + providerSuffix] = provider_; - } - - function factory(name, factoryFn) { return provider(name, { $get: factoryFn }); } - - function service(name, constructor) { - return factory(name, ['$injector', function($injector) { - return $injector.instantiate(constructor); - }]); - } - - function value(name, value) { return factory(name, valueFn(value)); } - - function constant(name, value) { - providerCache[name] = value; - instanceCache[name] = value; - } - - function decorator(serviceName, decorFn) { - var origProvider = providerInjector.get(serviceName + providerSuffix), - orig$get = origProvider.$get; - - origProvider.$get = function() { - var origInstance = instanceInjector.invoke(orig$get, origProvider); - return instanceInjector.invoke(decorFn, null, {$delegate: origInstance}); - }; - } - - //////////////////////////////////// - // Module Loading - //////////////////////////////////// - function loadModules(modulesToLoad){ - var runBlocks = []; - forEach(modulesToLoad, function(module) { - if (loadedModules.get(module)) return; - loadedModules.put(module, true); - if (isString(module)) { - var moduleFn = angularModule(module); - runBlocks = runBlocks.concat(loadModules(moduleFn.requires)).concat(moduleFn._runBlocks); - - try { - for(var invokeQueue = moduleFn._invokeQueue, i = 0, ii = invokeQueue.length; i < ii; i++) { - var invokeArgs = invokeQueue[i], - provider = invokeArgs[0] == '$injector' - ? providerInjector - : providerInjector.get(invokeArgs[0]); - - provider[invokeArgs[1]].apply(provider, invokeArgs[2]); - } - } catch (e) { - if (e.message) e.message += ' from ' + module; - throw e; - } - } else if (isFunction(module)) { - try { - runBlocks.push(providerInjector.invoke(module)); - } catch (e) { - if (e.message) e.message += ' from ' + module; - throw e; - } - } else if (isArray(module)) { - try { - runBlocks.push(providerInjector.invoke(module)); - } catch (e) { - if (e.message) e.message += ' from ' + String(module[module.length - 1]); - throw e; - } - } else { - assertArgFn(module, 'module'); - } - }); - return runBlocks; - } - - //////////////////////////////////// - // internal Injector - //////////////////////////////////// - - function createInternalInjector(cache, factory) { - - function getService(serviceName) { - if (typeof serviceName !== 'string') { - throw Error('Service name expected'); - } - if (cache.hasOwnProperty(serviceName)) { - if (cache[serviceName] === INSTANTIATING) { - throw Error('Circular dependency: ' + path.join(' <- ')); - } - return cache[serviceName]; - } else { - try { - path.unshift(serviceName); - cache[serviceName] = INSTANTIATING; - return cache[serviceName] = factory(serviceName); - } finally { - path.shift(); - } - } - } - - function invoke(fn, self, locals){ - var args = [], - $inject = annotate(fn), - length, i, - key; - - for(i = 0, length = $inject.length; i < length; i++) { - key = $inject[i]; - args.push( - locals && locals.hasOwnProperty(key) - ? locals[key] - : getService(key) - ); - } - if (!fn.$inject) { - // this means that we must be an array. - fn = fn[length]; - } - - - // Performance optimization: http://jsperf.com/apply-vs-call-vs-invoke - switch (self ? -1 : args.length) { - case 0: return fn(); - case 1: return fn(args[0]); - case 2: return fn(args[0], args[1]); - case 3: return fn(args[0], args[1], args[2]); - case 4: return fn(args[0], args[1], args[2], args[3]); - case 5: return fn(args[0], args[1], args[2], args[3], args[4]); - case 6: return fn(args[0], args[1], args[2], args[3], args[4], args[5]); - case 7: return fn(args[0], args[1], args[2], args[3], args[4], args[5], args[6]); - case 8: return fn(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7]); - case 9: return fn(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7], args[8]); - case 10: return fn(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7], args[8], args[9]); - default: return fn.apply(self, args); - } - } - - function instantiate(Type, locals) { - var Constructor = function() {}, - instance, returnedValue; - - Constructor.prototype = (isArray(Type) ? Type[Type.length - 1] : Type).prototype; - instance = new Constructor(); - returnedValue = invoke(Type, instance, locals); - - return isObject(returnedValue) ? returnedValue : instance; - } - - return { - invoke: invoke, - instantiate: instantiate, - get: getService, - annotate: annotate - }; - } -} -/** - * @ngdoc function - * @name ng.$anchorScroll - * @requires $window - * @requires $location - * @requires $rootScope - * - * @description - * When called, it checks current value of `$location.hash()` and scroll to related element, - * according to rules specified in - * {@link http://dev.w3.org/html5/spec/Overview.html#the-indicated-part-of-the-document Html5 spec}. - * - * It also watches the `$location.hash()` and scroll whenever it changes to match any anchor. - * This can be disabled by calling `$anchorScrollProvider.disableAutoScrolling()`. - */ -function $AnchorScrollProvider() { - - var autoScrollingEnabled = true; - - this.disableAutoScrolling = function() { - autoScrollingEnabled = false; - }; - - this.$get = ['$window', '$location', '$rootScope', function($window, $location, $rootScope) { - var document = $window.document; - - // helper function to get first anchor from a NodeList - // can't use filter.filter, as it accepts only instances of Array - // and IE can't convert NodeList to an array using [].slice - // TODO(vojta): use filter if we change it to accept lists as well - function getFirstAnchor(list) { - var result = null; - forEach(list, function(element) { - if (!result && lowercase(element.nodeName) === 'a') result = element; - }); - return result; - } - - function scroll() { - var hash = $location.hash(), elm; - - // empty hash, scroll to the top of the page - if (!hash) $window.scrollTo(0, 0); - - // element with given id - else if ((elm = document.getElementById(hash))) elm.scrollIntoView(); - - // first anchor with given name :-D - else if ((elm = getFirstAnchor(document.getElementsByName(hash)))) elm.scrollIntoView(); - - // no element and hash == 'top', scroll to the top of the page - else if (hash === 'top') $window.scrollTo(0, 0); - } - - // does not scroll when user clicks on anchor link that is currently on - // (no url change, no $location.hash() change), browser native does scroll - if (autoScrollingEnabled) { - $rootScope.$watch(function autoScrollWatch() {return $location.hash();}, - function autoScrollWatchAction() { - $rootScope.$evalAsync(scroll); - }); - } - - return scroll; - }]; -} - -/** - * ! This is a private undocumented service ! - * - * @name ng.$browser - * @requires $log - * @description - * This object has two goals: - * - * - hide all the global state in the browser caused by the window object - * - abstract away all the browser specific features and inconsistencies - * - * For tests we provide {@link ngMock.$browser mock implementation} of the `$browser` - * service, which can be used for convenient testing of the application without the interaction with - * the real browser apis. - */ -/** - * @param {object} window The global window object. - * @param {object} document jQuery wrapped document. - * @param {function()} XHR XMLHttpRequest constructor. - * @param {object} $log console.log or an object with the same interface. - * @param {object} $sniffer $sniffer service - */ -function Browser(window, document, $log, $sniffer) { - var self = this, - rawDocument = document[0], - location = window.location, - history = window.history, - setTimeout = window.setTimeout, - clearTimeout = window.clearTimeout, - pendingDeferIds = {}; - - self.isMock = false; - - var outstandingRequestCount = 0; - var outstandingRequestCallbacks = []; - - // TODO(vojta): remove this temporary api - self.$$completeOutstandingRequest = completeOutstandingRequest; - self.$$incOutstandingRequestCount = function() { outstandingRequestCount++; }; - - /** - * Executes the `fn` function(supports currying) and decrements the `outstandingRequestCallbacks` - * counter. If the counter reaches 0, all the `outstandingRequestCallbacks` are executed. - */ - function completeOutstandingRequest(fn) { - try { - fn.apply(null, sliceArgs(arguments, 1)); - } finally { - outstandingRequestCount--; - if (outstandingRequestCount === 0) { - while(outstandingRequestCallbacks.length) { - try { - outstandingRequestCallbacks.pop()(); - } catch (e) { - $log.error(e); - } - } - } - } - } - - /** - * @private - * Note: this method is used only by scenario runner - * TODO(vojta): prefix this method with $$ ? - * @param {function()} callback Function that will be called when no outstanding request - */ - self.notifyWhenNoOutstandingRequests = function(callback) { - // force browser to execute all pollFns - this is needed so that cookies and other pollers fire - // at some deterministic time in respect to the test runner's actions. Leaving things up to the - // regular poller would result in flaky tests. - forEach(pollFns, function(pollFn){ pollFn(); }); - - if (outstandingRequestCount === 0) { - callback(); - } else { - outstandingRequestCallbacks.push(callback); - } - }; - - ////////////////////////////////////////////////////////////// - // Poll Watcher API - ////////////////////////////////////////////////////////////// - var pollFns = [], - pollTimeout; - - /** - * @name ng.$browser#addPollFn - * @methodOf ng.$browser - * - * @param {function()} fn Poll function to add - * - * @description - * Adds a function to the list of functions that poller periodically executes, - * and starts polling if not started yet. - * - * @returns {function()} the added function - */ - self.addPollFn = function(fn) { - if (isUndefined(pollTimeout)) startPoller(100, setTimeout); - pollFns.push(fn); - return fn; - }; - - /** - * @param {number} interval How often should browser call poll functions (ms) - * @param {function()} setTimeout Reference to a real or fake `setTimeout` function. - * - * @description - * Configures the poller to run in the specified intervals, using the specified - * setTimeout fn and kicks it off. - */ - function startPoller(interval, setTimeout) { - (function check() { - forEach(pollFns, function(pollFn){ pollFn(); }); - pollTimeout = setTimeout(check, interval); - })(); - } - - ////////////////////////////////////////////////////////////// - // URL API - ////////////////////////////////////////////////////////////// - - var lastBrowserUrl = location.href, - baseElement = document.find('base'); - - /** - * @name ng.$browser#url - * @methodOf ng.$browser - * - * @description - * GETTER: - * Without any argument, this method just returns current value of location.href. - * - * SETTER: - * With at least one argument, this method sets url to new value. - * If html5 history api supported, pushState/replaceState is used, otherwise - * location.href/location.replace is used. - * Returns its own instance to allow chaining - * - * NOTE: this api is intended for use only by the $location service. Please use the - * {@link ng.$location $location service} to change url. - * - * @param {string} url New url (when used as setter) - * @param {boolean=} replace Should new url replace current history record ? - */ - self.url = function(url, replace) { - // setter - if (url) { - if (lastBrowserUrl == url) return; - lastBrowserUrl = url; - if ($sniffer.history) { - if (replace) history.replaceState(null, '', url); - else { - history.pushState(null, '', url); - // Crazy Opera Bug: http://my.opera.com/community/forums/topic.dml?id=1185462 - baseElement.attr('href', baseElement.attr('href')); - } - } else { - if (replace) location.replace(url); - else location.href = url; - } - return self; - // getter - } else { - // the replacement is a workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=407172 - return location.href.replace(/%27/g,"'"); - } - }; - - var urlChangeListeners = [], - urlChangeInit = false; - - function fireUrlChange() { - if (lastBrowserUrl == self.url()) return; - - lastBrowserUrl = self.url(); - forEach(urlChangeListeners, function(listener) { - listener(self.url()); - }); - } - - /** - * @name ng.$browser#onUrlChange - * @methodOf ng.$browser - * @TODO(vojta): refactor to use node's syntax for events - * - * @description - * Register callback function that will be called, when url changes. - * - * It's only called when the url is changed by outside of angular: - * - user types different url into address bar - * - user clicks on history (forward/back) button - * - user clicks on a link - * - * It's not called when url is changed by $browser.url() method - * - * The listener gets called with new url as parameter. - * - * NOTE: this api is intended for use only by the $location service. Please use the - * {@link ng.$location $location service} to monitor url changes in angular apps. - * - * @param {function(string)} listener Listener function to be called when url changes. - * @return {function(string)} Returns the registered listener fn - handy if the fn is anonymous. - */ - self.onUrlChange = function(callback) { - if (!urlChangeInit) { - // We listen on both (hashchange/popstate) when available, as some browsers (e.g. Opera) - // don't fire popstate when user change the address bar and don't fire hashchange when url - // changed by push/replaceState - - // html5 history api - popstate event - if ($sniffer.history) jqLite(window).bind('popstate', fireUrlChange); - // hashchange event - if ($sniffer.hashchange) jqLite(window).bind('hashchange', fireUrlChange); - // polling - else self.addPollFn(fireUrlChange); - - urlChangeInit = true; - } - - urlChangeListeners.push(callback); - return callback; - }; - - ////////////////////////////////////////////////////////////// - // Misc API - ////////////////////////////////////////////////////////////// - - /** - * Returns current - * (always relative - without domain) - * - * @returns {string=} - */ - self.baseHref = function() { - var href = baseElement.attr('href'); - return href ? href.replace(/^https?\:\/\/[^\/]*/, '') : ''; - }; - - ////////////////////////////////////////////////////////////// - // Cookies API - ////////////////////////////////////////////////////////////// - var lastCookies = {}; - var lastCookieString = ''; - var cookiePath = self.baseHref(); - - /** - * @name ng.$browser#cookies - * @methodOf ng.$browser - * - * @param {string=} name Cookie name - * @param {string=} value Cokkie value - * - * @description - * The cookies method provides a 'private' low level access to browser cookies. - * It is not meant to be used directly, use the $cookie service instead. - * - * The return values vary depending on the arguments that the method was called with as follows: - * - * cookies() -> hash of all cookies, this is NOT a copy of the internal state, so do not modify it - * cookies(name, value) -> set name to value, if value is undefined delete the cookie - * cookies(name) -> the same as (name, undefined) == DELETES (no one calls it right now that way) - * - * - * @returns {Object} Hash of all cookies (if called without any parameter) - */ - self.cookies = function(name, value) { - var cookieLength, cookieArray, cookie, i, index; - - if (name) { - if (value === undefined) { - rawDocument.cookie = escape(name) + "=;path=" + cookiePath + ";expires=Thu, 01 Jan 1970 00:00:00 GMT"; - } else { - if (isString(value)) { - cookieLength = (rawDocument.cookie = escape(name) + '=' + escape(value) + ';path=' + cookiePath).length + 1; - - // per http://www.ietf.org/rfc/rfc2109.txt browser must allow at minimum: - // - 300 cookies - // - 20 cookies per unique domain - // - 4096 bytes per cookie - if (cookieLength > 4096) { - $log.warn("Cookie '"+ name +"' possibly not set or overflowed because it was too large ("+ - cookieLength + " > 4096 bytes)!"); - } - } - } - } else { - if (rawDocument.cookie !== lastCookieString) { - lastCookieString = rawDocument.cookie; - cookieArray = lastCookieString.split("; "); - lastCookies = {}; - - for (i = 0; i < cookieArray.length; i++) { - cookie = cookieArray[i]; - index = cookie.indexOf('='); - if (index > 0) { //ignore nameless cookies - lastCookies[unescape(cookie.substring(0, index))] = unescape(cookie.substring(index + 1)); - } - } - } - return lastCookies; - } - }; - - - /** - * @name ng.$browser#defer - * @methodOf ng.$browser - * @param {function()} fn A function, who's execution should be defered. - * @param {number=} [delay=0] of milliseconds to defer the function execution. - * @returns {*} DeferId that can be used to cancel the task via `$browser.defer.cancel()`. - * - * @description - * Executes a fn asynchroniously via `setTimeout(fn, delay)`. - * - * Unlike when calling `setTimeout` directly, in test this function is mocked and instead of using - * `setTimeout` in tests, the fns are queued in an array, which can be programmatically flushed - * via `$browser.defer.flush()`. - * - */ - self.defer = function(fn, delay) { - var timeoutId; - outstandingRequestCount++; - timeoutId = setTimeout(function() { - delete pendingDeferIds[timeoutId]; - completeOutstandingRequest(fn); - }, delay || 0); - pendingDeferIds[timeoutId] = true; - return timeoutId; - }; - - - /** - * @name ng.$browser#defer.cancel - * @methodOf ng.$browser.defer - * - * @description - * Cancels a defered task identified with `deferId`. - * - * @param {*} deferId Token returned by the `$browser.defer` function. - * @returns {boolean} Returns `true` if the task hasn't executed yet and was successfuly canceled. - */ - self.defer.cancel = function(deferId) { - if (pendingDeferIds[deferId]) { - delete pendingDeferIds[deferId]; - clearTimeout(deferId); - completeOutstandingRequest(noop); - return true; - } - return false; - }; - -} - -function $BrowserProvider(){ - this.$get = ['$window', '$log', '$sniffer', '$document', - function( $window, $log, $sniffer, $document){ - return new Browser($window, $document, $log, $sniffer); - }]; -} -/** - * @ngdoc object - * @name ng.$cacheFactory - * - * @description - * Factory that constructs cache objects. - * - * - * @param {string} cacheId Name or id of the newly created cache. - * @param {object=} options Options object that specifies the cache behavior. Properties: - * - * - `{number=}` `capacity` — turns the cache into LRU cache. - * - * @returns {object} Newly created cache object with the following set of methods: - * - * - `{object}` `info()` — Returns id, size, and options of cache. - * - `{void}` `put({string} key, {*} value)` — Puts a new key-value pair into the cache. - * - `{{*}}` `get({string} key)` — Returns cached value for `key` or undefined for cache miss. - * - `{void}` `remove({string} key)` — Removes a key-value pair from the cache. - * - `{void}` `removeAll()` — Removes all cached values. - * - `{void}` `destroy()` — Removes references to this cache from $cacheFactory. - * - */ -function $CacheFactoryProvider() { - - this.$get = function() { - var caches = {}; - - function cacheFactory(cacheId, options) { - if (cacheId in caches) { - throw Error('cacheId ' + cacheId + ' taken'); - } - - var size = 0, - stats = extend({}, options, {id: cacheId}), - data = {}, - capacity = (options && options.capacity) || Number.MAX_VALUE, - lruHash = {}, - freshEnd = null, - staleEnd = null; - - return caches[cacheId] = { - - put: function(key, value) { - var lruEntry = lruHash[key] || (lruHash[key] = {key: key}); - - refresh(lruEntry); - - if (isUndefined(value)) return; - if (!(key in data)) size++; - data[key] = value; - - if (size > capacity) { - this.remove(staleEnd.key); - } - }, - - - get: function(key) { - var lruEntry = lruHash[key]; - - if (!lruEntry) return; - - refresh(lruEntry); - - return data[key]; - }, - - - remove: function(key) { - var lruEntry = lruHash[key]; - - if (!lruEntry) return; - - if (lruEntry == freshEnd) freshEnd = lruEntry.p; - if (lruEntry == staleEnd) staleEnd = lruEntry.n; - link(lruEntry.n,lruEntry.p); - - delete lruHash[key]; - delete data[key]; - size--; - }, - - - removeAll: function() { - data = {}; - size = 0; - lruHash = {}; - freshEnd = staleEnd = null; - }, - - - destroy: function() { - data = null; - stats = null; - lruHash = null; - delete caches[cacheId]; - }, - - - info: function() { - return extend({}, stats, {size: size}); - } - }; - - - /** - * makes the `entry` the freshEnd of the LRU linked list - */ - function refresh(entry) { - if (entry != freshEnd) { - if (!staleEnd) { - staleEnd = entry; - } else if (staleEnd == entry) { - staleEnd = entry.n; - } - - link(entry.n, entry.p); - link(entry, freshEnd); - freshEnd = entry; - freshEnd.n = null; - } - } - - - /** - * bidirectionally links two entries of the LRU linked list - */ - function link(nextEntry, prevEntry) { - if (nextEntry != prevEntry) { - if (nextEntry) nextEntry.p = prevEntry; //p stands for previous, 'prev' didn't minify - if (prevEntry) prevEntry.n = nextEntry; //n stands for next, 'next' didn't minify - } - } - } - - - cacheFactory.info = function() { - var info = {}; - forEach(caches, function(cache, cacheId) { - info[cacheId] = cache.info(); - }); - return info; - }; - - - cacheFactory.get = function(cacheId) { - return caches[cacheId]; - }; - - - return cacheFactory; - }; -} - -/** - * @ngdoc object - * @name ng.$templateCache - * - * @description - * Cache used for storing html templates. - * - * See {@link ng.$cacheFactory $cacheFactory}. - * - */ -function $TemplateCacheProvider() { - this.$get = ['$cacheFactory', function($cacheFactory) { - return $cacheFactory('templates'); - }]; -} - -/* ! VARIABLE/FUNCTION NAMING CONVENTIONS THAT APPLY TO THIS FILE! - * - * DOM-related variables: - * - * - "node" - DOM Node - * - "element" - DOM Element or Node - * - "$node" or "$element" - jqLite-wrapped node or element - * - * - * Compiler related stuff: - * - * - "linkFn" - linking fn of a single directive - * - "nodeLinkFn" - function that aggregates all linking fns for a particular node - * - "childLinkFn" - function that aggregates all linking fns for child nodes of a particular node - * - "compositeLinkFn" - function that aggregates all linking fns for a compilation root (nodeList) - */ - - -var NON_ASSIGNABLE_MODEL_EXPRESSION = 'Non-assignable model expression: '; - - -/** - * @ngdoc function - * @name ng.$compile - * @function - * - * @description - * Compiles a piece of HTML string or DOM into a template and produces a template function, which - * can then be used to link {@link ng.$rootScope.Scope scope} and the template together. - * - * The compilation is a process of walking the DOM tree and trying to match DOM elements to - * {@link ng.$compileProvider#directive directives}. For each match it - * executes corresponding template function and collects the - * instance functions into a single template function which is then returned. - * - * The template function can then be used once to produce the view or as it is the case with - * {@link ng.directive:ngRepeat repeater} many-times, in which - * case each call results in a view that is a DOM clone of the original template. - * - - - - - - - - - - - it('should auto compile', function() { - expect(element('div[compile]').text()).toBe('Hello Angular'); - input('html').enter('{{name}}!'); - expect(element('div[compile]').text()).toBe('Angular!'); - }); - - - - * - * - * @param {string|DOMElement} element Element or HTML string to compile into a template function. - * @param {function(angular.Scope[, cloneAttachFn]} transclude function available to directives. - * @param {number} maxPriority only apply directives lower then given priority (Only effects the - * root element(s), not their children) - * @returns {function(scope[, cloneAttachFn])} a link function which is used to bind template - * (a DOM element/tree) to a scope. Where: - * - * * `scope` - A {@link ng.$rootScope.Scope Scope} to bind to. - * * `cloneAttachFn` - If `cloneAttachFn` is provided, then the link function will clone the - * `template` and call the `cloneAttachFn` function allowing the caller to attach the - * cloned elements to the DOM document at the appropriate place. The `cloneAttachFn` is - * called as: `cloneAttachFn(clonedElement, scope)` where: - * - * * `clonedElement` - is a clone of the original `element` passed into the compiler. - * * `scope` - is the current scope with which the linking function is working with. - * - * Calling the linking function returns the element of the template. It is either the original element - * passed in, or the clone of the element if the `cloneAttachFn` is provided. - * - * After linking the view is not updated until after a call to $digest which typically is done by - * Angular automatically. - * - * If you need access to the bound view, there are two ways to do it: - * - * - If you are not asking the linking function to clone the template, create the DOM element(s) - * before you send them to the compiler and keep this reference around. - * - * var element = $compile('{{total}}')(scope); - * - * - * - if on the other hand, you need the element to be cloned, the view reference from the original - * example would not point to the clone, but rather to the original template that was cloned. In - * this case, you can access the clone via the cloneAttachFn: - * - * var templateHTML = angular.element('{{total}}'), - * scope = ....; - * - * var clonedElement = $compile(templateHTML)(scope, function(clonedElement, scope) { - * //attach the clone to DOM document at the right place - * }); - * - * //now we have reference to the cloned DOM via `clone` - * - * - * - * For information on how the compiler works, see the - * {@link guide/compiler Angular HTML Compiler} section of the Developer Guide. - */ - - -/** - * @ngdoc service - * @name ng.$compileProvider - * @function - * - * @description - */ -$CompileProvider.$inject = ['$provide']; -function $CompileProvider($provide) { - var hasDirectives = {}, - Suffix = 'Directive', - COMMENT_DIRECTIVE_REGEXP = /^\s*directive\:\s*([\d\w\-_]+)\s+(.*)$/, - CLASS_DIRECTIVE_REGEXP = /(([\d\w\-_]+)(?:\:([^;]+))?;?)/, - MULTI_ROOT_TEMPLATE_ERROR = 'Template must have exactly one root element. was: ', - urlSanitizationWhitelist = /^\s*(https?|ftp|mailto):/; - - - /** - * @ngdoc function - * @name ng.$compileProvider#directive - * @methodOf ng.$compileProvider - * @function - * - * @description - * Register a new directives with the compiler. - * - * @param {string} name Name of the directive in camel-case. (ie ngBind which will match as - * ng-bind). - * @param {function} directiveFactory An injectable directive factroy function. See {@link guide/directive} for more - * info. - * @returns {ng.$compileProvider} Self for chaining. - */ - this.directive = function registerDirective(name, directiveFactory) { - if (isString(name)) { - assertArg(directiveFactory, 'directive'); - if (!hasDirectives.hasOwnProperty(name)) { - hasDirectives[name] = []; - $provide.factory(name + Suffix, ['$injector', '$exceptionHandler', - function($injector, $exceptionHandler) { - var directives = []; - forEach(hasDirectives[name], function(directiveFactory) { - try { - var directive = $injector.invoke(directiveFactory); - if (isFunction(directive)) { - directive = { compile: valueFn(directive) }; - } else if (!directive.compile && directive.link) { - directive.compile = valueFn(directive.link); - } - directive.priority = directive.priority || 0; - directive.name = directive.name || name; - directive.require = directive.require || (directive.controller && directive.name); - directive.restrict = directive.restrict || 'A'; - directives.push(directive); - } catch (e) { - $exceptionHandler(e); - } - }); - return directives; - }]); - } - hasDirectives[name].push(directiveFactory); - } else { - forEach(name, reverseParams(registerDirective)); - } - return this; - }; - - - /** - * @ngdoc function - * @name ng.$compileProvider#urlSanitizationWhitelist - * @methodOf ng.$compileProvider - * @function - * - * @description - * Retrieves or overrides the default regular expression that is used for whitelisting of safe - * urls during a[href] sanitization. - * - * The sanitization is a security measure aimed at prevent XSS attacks via html links. - * - * Any url about to be assigned to a[href] via data-binding is first normalized and turned into an - * absolute url. Afterwards the url is matched against the `urlSanitizationWhitelist` regular - * expression. If a match is found the original url is written into the dom. Otherwise the - * absolute url is prefixed with `'unsafe:'` string and only then it is written into the DOM. - * - * @param {RegExp=} regexp New regexp to whitelist urls with. - * @returns {RegExp|ng.$compileProvider} Current RegExp if called without value or self for - * chaining otherwise. - */ - this.urlSanitizationWhitelist = function(regexp) { - if (isDefined(regexp)) { - urlSanitizationWhitelist = regexp; - return this; - } - return urlSanitizationWhitelist; - }; - - - this.$get = [ - '$injector', '$interpolate', '$exceptionHandler', '$http', '$templateCache', '$parse', - '$controller', '$rootScope', '$document', - function($injector, $interpolate, $exceptionHandler, $http, $templateCache, $parse, - $controller, $rootScope, $document) { - - var Attributes = function(element, attr) { - this.$$element = element; - this.$attr = attr || {}; - }; - - Attributes.prototype = { - $normalize: directiveNormalize, - - - /** - * Set a normalized attribute on the element in a way such that all directives - * can share the attribute. This function properly handles boolean attributes. - * @param {string} key Normalized key. (ie ngAttribute) - * @param {string|boolean} value The value to set. If `null` attribute will be deleted. - * @param {boolean=} writeAttr If false, does not write the value to DOM element attribute. - * Defaults to true. - * @param {string=} attrName Optional none normalized name. Defaults to key. - */ - $set: function(key, value, writeAttr, attrName) { - var booleanKey = getBooleanAttrName(this.$$element[0], key), - $$observers = this.$$observers, - normalizedVal; - - if (booleanKey) { - this.$$element.prop(key, value); - attrName = booleanKey; - } - - this[key] = value; - - // translate normalized key to actual key - if (attrName) { - this.$attr[key] = attrName; - } else { - attrName = this.$attr[key]; - if (!attrName) { - this.$attr[key] = attrName = snake_case(key, '-'); - } - } - - - // sanitize a[href] values - if (nodeName_(this.$$element[0]) === 'A' && key === 'href') { - urlSanitizationNode.setAttribute('href', value); - - // href property always returns normalized absolute url, so we can match against that - normalizedVal = urlSanitizationNode.href; - if (!normalizedVal.match(urlSanitizationWhitelist)) { - this[key] = value = 'unsafe:' + normalizedVal; - } - } - - - if (writeAttr !== false) { - if (value === null || value === undefined) { - this.$$element.removeAttr(attrName); - } else { - this.$$element.attr(attrName, value); - } - } - - // fire observers - $$observers && forEach($$observers[key], function(fn) { - try { - fn(value); - } catch (e) { - $exceptionHandler(e); - } - }); - }, - - - /** - * Observe an interpolated attribute. - * The observer will never be called, if given attribute is not interpolated. - * - * @param {string} key Normalized key. (ie ngAttribute) . - * @param {function(*)} fn Function that will be called whenever the attribute value changes. - * @returns {function(*)} the `fn` Function passed in. - */ - $observe: function(key, fn) { - var attrs = this, - $$observers = (attrs.$$observers || (attrs.$$observers = {})), - listeners = ($$observers[key] || ($$observers[key] = [])); - - listeners.push(fn); - $rootScope.$evalAsync(function() { - if (!listeners.$$inter) { - // no one registered attribute interpolation function, so lets call it manually - fn(attrs[key]); - } - }); - return fn; - } - }; - - var urlSanitizationNode = $document[0].createElement('a'), - startSymbol = $interpolate.startSymbol(), - endSymbol = $interpolate.endSymbol(), - denormalizeTemplate = (startSymbol == '{{' || endSymbol == '}}') - ? identity - : function denormalizeTemplate(template) { - return template.replace(/\{\{/g, startSymbol).replace(/}}/g, endSymbol); - }; - - - return compile; - - //================================ - - function compile($compileNodes, transcludeFn, maxPriority) { - if (!($compileNodes instanceof jqLite)) { - // jquery always rewraps, where as we need to preserve the original selector so that we can modify it. - $compileNodes = jqLite($compileNodes); - } - // We can not compile top level text elements since text nodes can be merged and we will - // not be able to attach scope data to them, so we will wrap them in - forEach($compileNodes, function(node, index){ - if (node.nodeType == 3 /* text node */ && node.nodeValue.match(/\S+/) /* non-empty */ ) { - $compileNodes[index] = jqLite(node).wrap('').parent()[0]; - } - }); - var compositeLinkFn = compileNodes($compileNodes, transcludeFn, $compileNodes, maxPriority); - return function publicLinkFn(scope, cloneConnectFn){ - assertArg(scope, 'scope'); - // important!!: we must call our jqLite.clone() since the jQuery one is trying to be smart - // and sometimes changes the structure of the DOM. - var $linkNode = cloneConnectFn - ? JQLitePrototype.clone.call($compileNodes) // IMPORTANT!!! - : $compileNodes; - - // Attach scope only to non-text nodes. - for(var i = 0, ii = $linkNode.length; i - addDirective(directives, - directiveNormalize(nodeName_(node).toLowerCase()), 'E', maxPriority); - - // iterate over the attributes - for (var attr, name, nName, value, nAttrs = node.attributes, - j = 0, jj = nAttrs && nAttrs.length; j < jj; j++) { - attr = nAttrs[j]; - if (attr.specified) { - name = attr.name; - nName = directiveNormalize(name.toLowerCase()); - attrsMap[nName] = name; - attrs[nName] = value = trim((msie && name == 'href') - ? decodeURIComponent(node.getAttribute(name, 2)) - : attr.value); - if (getBooleanAttrName(node, nName)) { - attrs[nName] = true; // presence means true - } - addAttrInterpolateDirective(node, directives, value, nName); - addDirective(directives, nName, 'A', maxPriority); - } - } - - // use class as directive - className = node.className; - if (isString(className) && className !== '') { - while (match = CLASS_DIRECTIVE_REGEXP.exec(className)) { - nName = directiveNormalize(match[2]); - if (addDirective(directives, nName, 'C', maxPriority)) { - attrs[nName] = trim(match[3]); - } - className = className.substr(match.index + match[0].length); - } - } - break; - case 3: /* Text Node */ - addTextInterpolateDirective(directives, node.nodeValue); - break; - case 8: /* Comment */ - try { - match = COMMENT_DIRECTIVE_REGEXP.exec(node.nodeValue); - if (match) { - nName = directiveNormalize(match[1]); - if (addDirective(directives, nName, 'M', maxPriority)) { - attrs[nName] = trim(match[2]); - } - } - } catch (e) { - // turns out that under some circumstances IE9 throws errors when one attempts to read comment's node value. - // Just ignore it and continue. (Can't seem to reproduce in test case.) - } - break; - } - - directives.sort(byPriority); - return directives; - } - - - /** - * Once the directives have been collected their compile functions is executed. This method - * is responsible for inlining directive templates as well as terminating the application - * of the directives if the terminal directive has been reached.. - * - * @param {Array} directives Array of collected directives to execute their compile function. - * this needs to be pre-sorted by priority order. - * @param {Node} compileNode The raw DOM node to apply the compile functions to - * @param {Object} templateAttrs The shared attribute function - * @param {function(angular.Scope[, cloneAttachFn]} transcludeFn A linking function, where the - * scope argument is auto-generated to the new child of the transcluded parent scope. - * @param {DOMElement} $rootElement If we are working on the root of the compile tree then this - * argument has the root jqLite array so that we can replace widgets on it. - * @returns linkFn - */ - function applyDirectivesToNode(directives, compileNode, templateAttrs, transcludeFn, $rootElement) { - var terminalPriority = -Number.MAX_VALUE, - preLinkFns = [], - postLinkFns = [], - newScopeDirective = null, - newIsolateScopeDirective = null, - templateDirective = null, - $compileNode = templateAttrs.$$element = jqLite(compileNode), - directive, - directiveName, - $template, - transcludeDirective, - childTranscludeFn = transcludeFn, - controllerDirectives, - linkFn, - directiveValue; - - // executes all directives on the current element - for(var i = 0, ii = directives.length; i < ii; i++) { - directive = directives[i]; - $template = undefined; - - if (terminalPriority > directive.priority) { - break; // prevent further processing of directives - } - - if (directiveValue = directive.scope) { - assertNoDuplicate('isolated scope', newIsolateScopeDirective, directive, $compileNode); - if (isObject(directiveValue)) { - safeAddClass($compileNode, 'ng-isolate-scope'); - newIsolateScopeDirective = directive; - } - safeAddClass($compileNode, 'ng-scope'); - newScopeDirective = newScopeDirective || directive; - } - - directiveName = directive.name; - - if (directiveValue = directive.controller) { - controllerDirectives = controllerDirectives || {}; - assertNoDuplicate("'" + directiveName + "' controller", - controllerDirectives[directiveName], directive, $compileNode); - controllerDirectives[directiveName] = directive; - } - - if (directiveValue = directive.transclude) { - assertNoDuplicate('transclusion', transcludeDirective, directive, $compileNode); - transcludeDirective = directive; - terminalPriority = directive.priority; - if (directiveValue == 'element') { - $template = jqLite(compileNode); - $compileNode = templateAttrs.$$element = - jqLite(document.createComment(' ' + directiveName + ': ' + templateAttrs[directiveName] + ' ')); - compileNode = $compileNode[0]; - replaceWith($rootElement, jqLite($template[0]), compileNode); - childTranscludeFn = compile($template, transcludeFn, terminalPriority); - } else { - $template = jqLite(JQLiteClone(compileNode)).contents(); - $compileNode.html(''); // clear contents - childTranscludeFn = compile($template, transcludeFn); - } - } - - if ((directiveValue = directive.template)) { - assertNoDuplicate('template', templateDirective, directive, $compileNode); - templateDirective = directive; - directiveValue = denormalizeTemplate(directiveValue); - - if (directive.replace) { - $template = jqLite('' + - trim(directiveValue) + - '').contents(); - compileNode = $template[0]; - - if ($template.length != 1 || compileNode.nodeType !== 1) { - throw new Error(MULTI_ROOT_TEMPLATE_ERROR + directiveValue); - } - - replaceWith($rootElement, $compileNode, compileNode); - - var newTemplateAttrs = {$attr: {}}; - - // combine directives from the original node and from the template: - // - take the array of directives for this element - // - split it into two parts, those that were already applied and those that weren't - // - collect directives from the template, add them to the second group and sort them - // - append the second group with new directives to the first group - directives = directives.concat( - collectDirectives( - compileNode, - directives.splice(i + 1, directives.length - (i + 1)), - newTemplateAttrs - ) - ); - mergeTemplateAttributes(templateAttrs, newTemplateAttrs); - - ii = directives.length; - } else { - $compileNode.html(directiveValue); - } - } - - if (directive.templateUrl) { - assertNoDuplicate('template', templateDirective, directive, $compileNode); - templateDirective = directive; - nodeLinkFn = compileTemplateUrl(directives.splice(i, directives.length - i), - nodeLinkFn, $compileNode, templateAttrs, $rootElement, directive.replace, - childTranscludeFn); - ii = directives.length; - } else if (directive.compile) { - try { - linkFn = directive.compile($compileNode, templateAttrs, childTranscludeFn); - if (isFunction(linkFn)) { - addLinkFns(null, linkFn); - } else if (linkFn) { - addLinkFns(linkFn.pre, linkFn.post); - } - } catch (e) { - $exceptionHandler(e, startingTag($compileNode)); - } - } - - if (directive.terminal) { - nodeLinkFn.terminal = true; - terminalPriority = Math.max(terminalPriority, directive.priority); - } - - } - - nodeLinkFn.scope = newScopeDirective && newScopeDirective.scope; - nodeLinkFn.transclude = transcludeDirective && childTranscludeFn; - - // might be normal or delayed nodeLinkFn depending on if templateUrl is present - return nodeLinkFn; - - //////////////////// - - function addLinkFns(pre, post) { - if (pre) { - pre.require = directive.require; - preLinkFns.push(pre); - } - if (post) { - post.require = directive.require; - postLinkFns.push(post); - } - } - - - function getControllers(require, $element) { - var value, retrievalMethod = 'data', optional = false; - if (isString(require)) { - while((value = require.charAt(0)) == '^' || value == '?') { - require = require.substr(1); - if (value == '^') { - retrievalMethod = 'inheritedData'; - } - optional = optional || value == '?'; - } - value = $element[retrievalMethod]('$' + require + 'Controller'); - if (!value && !optional) { - throw Error("No controller: " + require); - } - return value; - } else if (isArray(require)) { - value = []; - forEach(require, function(require) { - value.push(getControllers(require, $element)); - }); - } - return value; - } - - - function nodeLinkFn(childLinkFn, scope, linkNode, $rootElement, boundTranscludeFn) { - var attrs, $element, i, ii, linkFn, controller; - - if (compileNode === linkNode) { - attrs = templateAttrs; - } else { - attrs = shallowCopy(templateAttrs, new Attributes(jqLite(linkNode), templateAttrs.$attr)); - } - $element = attrs.$$element; - - if (newIsolateScopeDirective) { - var LOCAL_REGEXP = /^\s*([@=&])\s*(\w*)\s*$/; - - var parentScope = scope.$parent || scope; - - forEach(newIsolateScopeDirective.scope, function(definiton, scopeName) { - var match = definiton.match(LOCAL_REGEXP) || [], - attrName = match[2]|| scopeName, - mode = match[1], // @, =, or & - lastValue, - parentGet, parentSet; - - scope.$$isolateBindings[scopeName] = mode + attrName; - - switch (mode) { - - case '@': { - attrs.$observe(attrName, function(value) { - scope[scopeName] = value; - }); - attrs.$$observers[attrName].$$scope = parentScope; - break; - } - - case '=': { - parentGet = $parse(attrs[attrName]); - parentSet = parentGet.assign || function() { - // reset the change, or we will throw this exception on every $digest - lastValue = scope[scopeName] = parentGet(parentScope); - throw Error(NON_ASSIGNABLE_MODEL_EXPRESSION + attrs[attrName] + - ' (directive: ' + newIsolateScopeDirective.name + ')'); - }; - lastValue = scope[scopeName] = parentGet(parentScope); - scope.$watch(function parentValueWatch() { - var parentValue = parentGet(parentScope); - - if (parentValue !== scope[scopeName]) { - // we are out of sync and need to copy - if (parentValue !== lastValue) { - // parent changed and it has precedence - lastValue = scope[scopeName] = parentValue; - } else { - // if the parent can be assigned then do so - parentSet(parentScope, parentValue = lastValue = scope[scopeName]); - } - } - return parentValue; - }); - break; - } - - case '&': { - parentGet = $parse(attrs[attrName]); - scope[scopeName] = function(locals) { - return parentGet(parentScope, locals); - } - break; - } - - default: { - throw Error('Invalid isolate scope definition for directive ' + - newIsolateScopeDirective.name + ': ' + definiton); - } - } - }); - } - - if (controllerDirectives) { - forEach(controllerDirectives, function(directive) { - var locals = { - $scope: scope, - $element: $element, - $attrs: attrs, - $transclude: boundTranscludeFn - }; - - controller = directive.controller; - if (controller == '@') { - controller = attrs[directive.name]; - } - - $element.data( - '$' + directive.name + 'Controller', - $controller(controller, locals)); - }); - } - - // PRELINKING - for(i = 0, ii = preLinkFns.length; i < ii; i++) { - try { - linkFn = preLinkFns[i]; - linkFn(scope, $element, attrs, - linkFn.require && getControllers(linkFn.require, $element)); - } catch (e) { - $exceptionHandler(e, startingTag($element)); - } - } - - // RECURSION - childLinkFn && childLinkFn(scope, linkNode.childNodes, undefined, boundTranscludeFn); - - // POSTLINKING - for(i = 0, ii = postLinkFns.length; i < ii; i++) { - try { - linkFn = postLinkFns[i]; - linkFn(scope, $element, attrs, - linkFn.require && getControllers(linkFn.require, $element)); - } catch (e) { - $exceptionHandler(e, startingTag($element)); - } - } - } - } - - - /** - * looks up the directive and decorates it with exception handling and proper parameters. We - * call this the boundDirective. - * - * @param {string} name name of the directive to look up. - * @param {string} location The directive must be found in specific format. - * String containing any of theses characters: - * - * * `E`: element name - * * `A': attribute - * * `C`: class - * * `M`: comment - * @returns true if directive was added. - */ - function addDirective(tDirectives, name, location, maxPriority) { - var match = false; - if (hasDirectives.hasOwnProperty(name)) { - for(var directive, directives = $injector.get(name + Suffix), - i = 0, ii = directives.length; i directive.priority) && - directive.restrict.indexOf(location) != -1) { - tDirectives.push(directive); - match = true; - } - } catch(e) { $exceptionHandler(e); } - } - } - return match; - } - - - /** - * When the element is replaced with HTML template then the new attributes - * on the template need to be merged with the existing attributes in the DOM. - * The desired effect is to have both of the attributes present. - * - * @param {object} dst destination attributes (original DOM) - * @param {object} src source attributes (from the directive template) - */ - function mergeTemplateAttributes(dst, src) { - var srcAttr = src.$attr, - dstAttr = dst.$attr, - $element = dst.$$element; - - // reapply the old attributes to the new element - forEach(dst, function(value, key) { - if (key.charAt(0) != '$') { - if (src[key]) { - value += (key === 'style' ? ';' : ' ') + src[key]; - } - dst.$set(key, value, true, srcAttr[key]); - } - }); - - // copy the new attributes on the old attrs object - forEach(src, function(value, key) { - if (key == 'class') { - safeAddClass($element, value); - dst['class'] = (dst['class'] ? dst['class'] + ' ' : '') + value; - } else if (key == 'style') { - $element.attr('style', $element.attr('style') + ';' + value); - } else if (key.charAt(0) != '$' && !dst.hasOwnProperty(key)) { - dst[key] = value; - dstAttr[key] = srcAttr[key]; - } - }); - } - - - function compileTemplateUrl(directives, beforeTemplateNodeLinkFn, $compileNode, tAttrs, - $rootElement, replace, childTranscludeFn) { - var linkQueue = [], - afterTemplateNodeLinkFn, - afterTemplateChildLinkFn, - beforeTemplateCompileNode = $compileNode[0], - origAsyncDirective = directives.shift(), - // The fact that we have to copy and patch the directive seems wrong! - derivedSyncDirective = extend({}, origAsyncDirective, { - controller: null, templateUrl: null, transclude: null, scope: null - }); - - $compileNode.html(''); - - $http.get(origAsyncDirective.templateUrl, {cache: $templateCache}). - success(function(content) { - var compileNode, tempTemplateAttrs, $template; - - content = denormalizeTemplate(content); - - if (replace) { - $template = jqLite('' + trim(content) + '').contents(); - compileNode = $template[0]; - - if ($template.length != 1 || compileNode.nodeType !== 1) { - throw new Error(MULTI_ROOT_TEMPLATE_ERROR + content); - } - - tempTemplateAttrs = {$attr: {}}; - replaceWith($rootElement, $compileNode, compileNode); - collectDirectives(compileNode, directives, tempTemplateAttrs); - mergeTemplateAttributes(tAttrs, tempTemplateAttrs); - } else { - compileNode = beforeTemplateCompileNode; - $compileNode.html(content); - } - - directives.unshift(derivedSyncDirective); - afterTemplateNodeLinkFn = applyDirectivesToNode(directives, compileNode, tAttrs, childTranscludeFn); - afterTemplateChildLinkFn = compileNodes($compileNode.contents(), childTranscludeFn); - - - while(linkQueue.length) { - var controller = linkQueue.pop(), - linkRootElement = linkQueue.pop(), - beforeTemplateLinkNode = linkQueue.pop(), - scope = linkQueue.pop(), - linkNode = compileNode; - - if (beforeTemplateLinkNode !== beforeTemplateCompileNode) { - // it was cloned therefore we have to clone as well. - linkNode = JQLiteClone(compileNode); - replaceWith(linkRootElement, jqLite(beforeTemplateLinkNode), linkNode); - } - - afterTemplateNodeLinkFn(function() { - beforeTemplateNodeLinkFn(afterTemplateChildLinkFn, scope, linkNode, $rootElement, controller); - }, scope, linkNode, $rootElement, controller); - } - linkQueue = null; - }). - error(function(response, code, headers, config) { - throw Error('Failed to load template: ' + config.url); - }); - - return function delayedNodeLinkFn(ignoreChildLinkFn, scope, node, rootElement, controller) { - if (linkQueue) { - linkQueue.push(scope); - linkQueue.push(node); - linkQueue.push(rootElement); - linkQueue.push(controller); - } else { - afterTemplateNodeLinkFn(function() { - beforeTemplateNodeLinkFn(afterTemplateChildLinkFn, scope, node, rootElement, controller); - }, scope, node, rootElement, controller); - } - }; - } - - - /** - * Sorting function for bound directives. - */ - function byPriority(a, b) { - return b.priority - a.priority; - } - - - function assertNoDuplicate(what, previousDirective, directive, element) { - if (previousDirective) { - throw Error('Multiple directives [' + previousDirective.name + ', ' + - directive.name + '] asking for ' + what + ' on: ' + startingTag(element)); - } - } - - - function addTextInterpolateDirective(directives, text) { - var interpolateFn = $interpolate(text, true); - if (interpolateFn) { - directives.push({ - priority: 0, - compile: valueFn(function textInterpolateLinkFn(scope, node) { - var parent = node.parent(), - bindings = parent.data('$binding') || []; - bindings.push(interpolateFn); - safeAddClass(parent.data('$binding', bindings), 'ng-binding'); - scope.$watch(interpolateFn, function interpolateFnWatchAction(value) { - node[0].nodeValue = value; - }); - }) - }); - } - } - - - function addAttrInterpolateDirective(node, directives, value, name) { - var interpolateFn = $interpolate(value, true); - - // no interpolation found -> ignore - if (!interpolateFn) return; - - - directives.push({ - priority: 100, - compile: valueFn(function attrInterpolateLinkFn(scope, element, attr) { - var $$observers = (attr.$$observers || (attr.$$observers = {})); - - if (name === 'class') { - // we need to interpolate classes again, in the case the element was replaced - // and therefore the two class attrs got merged - we want to interpolate the result - interpolateFn = $interpolate(attr[name], true); - } - - attr[name] = undefined; - ($$observers[name] || ($$observers[name] = [])).$$inter = true; - (attr.$$observers && attr.$$observers[name].$$scope || scope). - $watch(interpolateFn, function interpolateFnWatchAction(value) { - attr.$set(name, value); - }); - }) - }); - } - - - /** - * This is a special jqLite.replaceWith, which can replace items which - * have no parents, provided that the containing jqLite collection is provided. - * - * @param {JqLite=} $rootElement The root of the compile tree. Used so that we can replace nodes - * in the root of the tree. - * @param {JqLite} $element The jqLite element which we are going to replace. We keep the shell, - * but replace its DOM node reference. - * @param {Node} newNode The new DOM node. - */ - function replaceWith($rootElement, $element, newNode) { - var oldNode = $element[0], - parent = oldNode.parentNode, - i, ii; - - if ($rootElement) { - for(i = 0, ii = $rootElement.length; i < ii; i++) { - if ($rootElement[i] == oldNode) { - $rootElement[i] = newNode; - break; - } - } - } - - if (parent) { - parent.replaceChild(newNode, oldNode); - } - - newNode[jqLite.expando] = oldNode[jqLite.expando]; - $element[0] = newNode; - } - }]; -} - -var PREFIX_REGEXP = /^(x[\:\-_]|data[\:\-_])/i; -/** - * Converts all accepted directives format into proper directive name. - * All of these will become 'myDirective': - * my:DiRective - * my-directive - * x-my-directive - * data-my:directive - * - * Also there is special case for Moz prefix starting with upper case letter. - * @param name Name to normalize - */ -function directiveNormalize(name) { - return camelCase(name.replace(PREFIX_REGEXP, '')); -} - -/** - * @ngdoc object - * @name ng.$compile.directive.Attributes - * @description - * - * A shared object between directive compile / linking functions which contains normalized DOM element - * attributes. The the values reflect current binding state `{{ }}`. The normalization is needed - * since all of these are treated as equivalent in Angular: - * - * - */ - -/** - * @ngdoc property - * @name ng.$compile.directive.Attributes#$attr - * @propertyOf ng.$compile.directive.Attributes - * @returns {object} A map of DOM element attribute names to the normalized name. This is - * needed to do reverse lookup from normalized name back to actual name. - */ - - -/** - * @ngdoc function - * @name ng.$compile.directive.Attributes#$set - * @methodOf ng.$compile.directive.Attributes - * @function - * - * @description - * Set DOM element attribute value. - * - * - * @param {string} name Normalized element attribute name of the property to modify. The name is - * revers translated using the {@link ng.$compile.directive.Attributes#$attr $attr} - * property to the original name. - * @param {string} value Value to set the attribute to. - */ - - - -/** - * Closure compiler type information - */ - -function nodesetLinkingFn( - /* angular.Scope */ scope, - /* NodeList */ nodeList, - /* Element */ rootElement, - /* function(Function) */ boundTranscludeFn -){} - -function directiveLinkingFn( - /* nodesetLinkingFn */ nodesetLinkingFn, - /* angular.Scope */ scope, - /* Node */ node, - /* Element */ rootElement, - /* function(Function) */ boundTranscludeFn -){} - -/** - * @ngdoc object - * @name ng.$controllerProvider - * @description - * The {@link ng.$controller $controller service} is used by Angular to create new - * controllers. - * - * This provider allows controller registration via the - * {@link ng.$controllerProvider#register register} method. - */ -function $ControllerProvider() { - var controllers = {}; - - - /** - * @ngdoc function - * @name ng.$controllerProvider#register - * @methodOf ng.$controllerProvider - * @param {string} name Controller name - * @param {Function|Array} constructor Controller constructor fn (optionally decorated with DI - * annotations in the array notation). - */ - this.register = function(name, constructor) { - if (isObject(name)) { - extend(controllers, name) - } else { - controllers[name] = constructor; - } - }; - - - this.$get = ['$injector', '$window', function($injector, $window) { - - /** - * @ngdoc function - * @name ng.$controller - * @requires $injector - * - * @param {Function|string} constructor If called with a function then it's considered to be the - * controller constructor function. Otherwise it's considered to be a string which is used - * to retrieve the controller constructor using the following steps: - * - * * check if a controller with given name is registered via `$controllerProvider` - * * check if evaluating the string on the current scope returns a constructor - * * check `window[constructor]` on the global `window` object - * - * @param {Object} locals Injection locals for Controller. - * @return {Object} Instance of given controller. - * - * @description - * `$controller` service is responsible for instantiating controllers. - * - * It's just simple call to {@link AUTO.$injector $injector}, but extracted into - * a service, so that one can override this service with {@link https://gist.github.com/1649788 - * BC version}. - */ - return function(constructor, locals) { - if(isString(constructor)) { - var name = constructor; - constructor = controllers.hasOwnProperty(name) - ? controllers[name] - : getter(locals.$scope, name, true) || getter($window, name, true); - - assertArgFn(constructor, name, true); - } - - return $injector.instantiate(constructor, locals); - }; - }]; -} - -/** - * @ngdoc object - * @name ng.$document - * @requires $window - * - * @description - * A {@link angular.element jQuery (lite)}-wrapped reference to the browser's `window.document` - * element. - */ -function $DocumentProvider(){ - this.$get = ['$window', function(window){ - return jqLite(window.document); - }]; -} - -/** - * @ngdoc function - * @name ng.$exceptionHandler - * @requires $log - * - * @description - * Any uncaught exception in angular expressions is delegated to this service. - * The default implementation simply delegates to `$log.error` which logs it into - * the browser console. - * - * In unit tests, if `angular-mocks.js` is loaded, this service is overridden by - * {@link ngMock.$exceptionHandler mock $exceptionHandler} which aids in testing. - * - * @param {Error} exception Exception associated with the error. - * @param {string=} cause optional information about the context in which - * the error was thrown. - * - */ -function $ExceptionHandlerProvider() { - this.$get = ['$log', function($log){ - return function(exception, cause) { - $log.error.apply($log, arguments); - }; - }]; -} - -/** - * @ngdoc object - * @name ng.$interpolateProvider - * @function - * - * @description - * - * Used for configuring the interpolation markup. Defaults to `{{` and `}}`. - */ -function $InterpolateProvider() { - var startSymbol = '{{'; - var endSymbol = '}}'; - - /** - * @ngdoc method - * @name ng.$interpolateProvider#startSymbol - * @methodOf ng.$interpolateProvider - * @description - * Symbol to denote start of expression in the interpolated string. Defaults to `{{`. - * - * @param {string=} value new value to set the starting symbol to. - * @returns {string|self} Returns the symbol when used as getter and self if used as setter. - */ - this.startSymbol = function(value){ - if (value) { - startSymbol = value; - return this; - } else { - return startSymbol; - } - }; - - /** - * @ngdoc method - * @name ng.$interpolateProvider#endSymbol - * @methodOf ng.$interpolateProvider - * @description - * Symbol to denote the end of expression in the interpolated string. Defaults to `}}`. - * - * @param {string=} value new value to set the ending symbol to. - * @returns {string|self} Returns the symbol when used as getter and self if used as setter. - */ - this.endSymbol = function(value){ - if (value) { - endSymbol = value; - return this; - } else { - return endSymbol; - } - }; - - - this.$get = ['$parse', function($parse) { - var startSymbolLength = startSymbol.length, - endSymbolLength = endSymbol.length; - - /** - * @ngdoc function - * @name ng.$interpolate - * @function - * - * @requires $parse - * - * @description - * - * Compiles a string with markup into an interpolation function. This service is used by the - * HTML {@link ng.$compile $compile} service for data binding. See - * {@link ng.$interpolateProvider $interpolateProvider} for configuring the - * interpolation markup. - * - * - - var $interpolate = ...; // injected - var exp = $interpolate('Hello {{name}}!'); - expect(exp({name:'Angular'}).toEqual('Hello Angular!'); - - * - * - * @param {string} text The text with markup to interpolate. - * @param {boolean=} mustHaveExpression if set to true then the interpolation string must have - * embedded expression in order to return an interpolation function. Strings with no - * embedded expression will return null for the interpolation function. - * @returns {function(context)} an interpolation function which is used to compute the interpolated - * string. The function has these parameters: - * - * * `context`: an object against which any expressions embedded in the strings are evaluated - * against. - * - */ - function $interpolate(text, mustHaveExpression) { - var startIndex, - endIndex, - index = 0, - parts = [], - length = text.length, - hasInterpolation = false, - fn, - exp, - concat = []; - - while(index < length) { - if ( ((startIndex = text.indexOf(startSymbol, index)) != -1) && - ((endIndex = text.indexOf(endSymbol, startIndex + startSymbolLength)) != -1) ) { - (index != startIndex) && parts.push(text.substring(index, startIndex)); - parts.push(fn = $parse(exp = text.substring(startIndex + startSymbolLength, endIndex))); - fn.exp = exp; - index = endIndex + endSymbolLength; - hasInterpolation = true; - } else { - // we did not find anything, so we have to add the remainder to the parts array - (index != length) && parts.push(text.substring(index)); - index = length; - } - } - - if (!(length = parts.length)) { - // we added, nothing, must have been an empty string. - parts.push(''); - length = 1; - } - - if (!mustHaveExpression || hasInterpolation) { - concat.length = length; - fn = function(context) { - for(var i = 0, ii = length, part; i html5 url - } else { - return composeProtocolHostPort(match.protocol, match.host, match.port) + - pathPrefixFromBase(basePath) + match.hash.substr(hashPrefix.length); - } -} - - -function convertToHashbangUrl(url, basePath, hashPrefix) { - var match = matchUrl(url); - - // already hashbang url - if (decodeURIComponent(match.path) == basePath) { - return url; - // convert html5 url -> hashbang url - } else { - var search = match.search && '?' + match.search || '', - hash = match.hash && '#' + match.hash || '', - pathPrefix = pathPrefixFromBase(basePath), - path = match.path.substr(pathPrefix.length); - - if (match.path.indexOf(pathPrefix) !== 0) { - throw Error('Invalid url "' + url + '", missing path prefix "' + pathPrefix + '" !'); - } - - return composeProtocolHostPort(match.protocol, match.host, match.port) + basePath + - '#' + hashPrefix + path + search + hash; - } -} - - -/** - * LocationUrl represents an url - * This object is exposed as $location service when HTML5 mode is enabled and supported - * - * @constructor - * @param {string} url HTML5 url - * @param {string} pathPrefix - */ -function LocationUrl(url, pathPrefix, appBaseUrl) { - pathPrefix = pathPrefix || ''; - - /** - * Parse given html5 (regular) url string into properties - * @param {string} newAbsoluteUrl HTML5 url - * @private - */ - this.$$parse = function(newAbsoluteUrl) { - var match = matchUrl(newAbsoluteUrl, this); - - if (match.path.indexOf(pathPrefix) !== 0) { - throw Error('Invalid url "' + newAbsoluteUrl + '", missing path prefix "' + pathPrefix + '" !'); - } - - this.$$path = decodeURIComponent(match.path.substr(pathPrefix.length)); - this.$$search = parseKeyValue(match.search); - this.$$hash = match.hash && decodeURIComponent(match.hash) || ''; - - this.$$compose(); - }; - - /** - * Compose url and update `absUrl` property - * @private - */ - this.$$compose = function() { - var search = toKeyValue(this.$$search), - hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : ''; - - this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; - this.$$absUrl = composeProtocolHostPort(this.$$protocol, this.$$host, this.$$port) + - pathPrefix + this.$$url; - }; - - - this.$$rewriteAppUrl = function(absoluteLinkUrl) { - if(absoluteLinkUrl.indexOf(appBaseUrl) == 0) { - return absoluteLinkUrl; - } - } - - - this.$$parse(url); -} - - -/** - * LocationHashbangUrl represents url - * This object is exposed as $location service when html5 history api is disabled or not supported - * - * @constructor - * @param {string} url Legacy url - * @param {string} hashPrefix Prefix for hash part (containing path and search) - */ -function LocationHashbangUrl(url, hashPrefix, appBaseUrl) { - var basePath; - - /** - * Parse given hashbang url into properties - * @param {string} url Hashbang url - * @private - */ - this.$$parse = function(url) { - var match = matchUrl(url, this); - - - if (match.hash && match.hash.indexOf(hashPrefix) !== 0) { - throw Error('Invalid url "' + url + '", missing hash prefix "' + hashPrefix + '" !'); - } - - basePath = match.path + (match.search ? '?' + match.search : ''); - match = HASH_MATCH.exec((match.hash || '').substr(hashPrefix.length)); - if (match[1]) { - this.$$path = (match[1].charAt(0) == '/' ? '' : '/') + decodeURIComponent(match[1]); - } else { - this.$$path = ''; - } - - this.$$search = parseKeyValue(match[3]); - this.$$hash = match[5] && decodeURIComponent(match[5]) || ''; - - this.$$compose(); - }; - - /** - * Compose hashbang url and update `absUrl` property - * @private - */ - this.$$compose = function() { - var search = toKeyValue(this.$$search), - hash = this.$$hash ? '#' + encodeUriSegment(this.$$hash) : ''; - - this.$$url = encodePath(this.$$path) + (search ? '?' + search : '') + hash; - this.$$absUrl = composeProtocolHostPort(this.$$protocol, this.$$host, this.$$port) + - basePath + (this.$$url ? '#' + hashPrefix + this.$$url : ''); - }; - - this.$$rewriteAppUrl = function(absoluteLinkUrl) { - if(absoluteLinkUrl.indexOf(appBaseUrl) == 0) { - return absoluteLinkUrl; - } - } - - - this.$$parse(url); -} - - -LocationUrl.prototype = { - - /** - * Has any change been replacing ? - * @private - */ - $$replace: false, - - /** - * @ngdoc method - * @name ng.$location#absUrl - * @methodOf ng.$location - * - * @description - * This method is getter only. - * - * Return full url representation with all segments encoded according to rules specified in - * {@link http://www.ietf.org/rfc/rfc3986.txt RFC 3986}. - * - * @return {string} full url - */ - absUrl: locationGetter('$$absUrl'), - - /** - * @ngdoc method - * @name ng.$location#url - * @methodOf ng.$location - * - * @description - * This method is getter / setter. - * - * Return url (e.g. `/path?a=b#hash`) when called without any parameter. - * - * Change path, search and hash, when called with parameter and return `$location`. - * - * @param {string=} url New url without base prefix (e.g. `/path?a=b#hash`) - * @return {string} url - */ - url: function(url, replace) { - if (isUndefined(url)) - return this.$$url; - - var match = PATH_MATCH.exec(url); - if (match[1]) this.path(decodeURIComponent(match[1])); - if (match[2] || match[1]) this.search(match[3] || ''); - this.hash(match[5] || '', replace); - - return this; - }, - - /** - * @ngdoc method - * @name ng.$location#protocol - * @methodOf ng.$location - * - * @description - * This method is getter only. - * - * Return protocol of current url. - * - * @return {string} protocol of current url - */ - protocol: locationGetter('$$protocol'), - - /** - * @ngdoc method - * @name ng.$location#host - * @methodOf ng.$location - * - * @description - * This method is getter only. - * - * Return host of current url. - * - * @return {string} host of current url. - */ - host: locationGetter('$$host'), - - /** - * @ngdoc method - * @name ng.$location#port - * @methodOf ng.$location - * - * @description - * This method is getter only. - * - * Return port of current url. - * - * @return {Number} port - */ - port: locationGetter('$$port'), - - /** - * @ngdoc method - * @name ng.$location#path - * @methodOf ng.$location - * - * @description - * This method is getter / setter. - * - * Return path of current url when called without any parameter. - * - * Change path when called with parameter and return `$location`. - * - * Note: Path should always begin with forward slash (/), this method will add the forward slash - * if it is missing. - * - * @param {string=} path New path - * @return {string} path - */ - path: locationGetterSetter('$$path', function(path) { - return path.charAt(0) == '/' ? path : '/' + path; - }), - - /** - * @ngdoc method - * @name ng.$location#search - * @methodOf ng.$location - * - * @description - * This method is getter / setter. - * - * Return search part (as object) of current url when called without any parameter. - * - * Change search part when called with parameter and return `$location`. - * - * @param {string|object=} search New search params - string or hash object - * @param {string=} paramValue If `search` is a string, then `paramValue` will override only a - * single search parameter. If the value is `null`, the parameter will be deleted. - * - * @return {string} search - */ - search: function(search, paramValue) { - if (isUndefined(search)) - return this.$$search; - - if (isDefined(paramValue)) { - if (paramValue === null) { - delete this.$$search[search]; - } else { - this.$$search[search] = paramValue; - } - } else { - this.$$search = isString(search) ? parseKeyValue(search) : search; - } - - this.$$compose(); - return this; - }, - - /** - * @ngdoc method - * @name ng.$location#hash - * @methodOf ng.$location - * - * @description - * This method is getter / setter. - * - * Return hash fragment when called without any parameter. - * - * Change hash fragment when called with parameter and return `$location`. - * - * @param {string=} hash New hash fragment - * @return {string} hash - */ - hash: locationGetterSetter('$$hash', identity), - - /** - * @ngdoc method - * @name ng.$location#replace - * @methodOf ng.$location - * - * @description - * If called, all changes to $location during current `$digest` will be replacing current history - * record, instead of adding new one. - */ - replace: function() { - this.$$replace = true; - return this; - } -}; - -LocationHashbangUrl.prototype = inherit(LocationUrl.prototype); - -function LocationHashbangInHtml5Url(url, hashPrefix, appBaseUrl, baseExtra) { - LocationHashbangUrl.apply(this, arguments); - - - this.$$rewriteAppUrl = function(absoluteLinkUrl) { - if (absoluteLinkUrl.indexOf(appBaseUrl) == 0) { - return appBaseUrl + baseExtra + '#' + hashPrefix + absoluteLinkUrl.substr(appBaseUrl.length); - } - } -} - -LocationHashbangInHtml5Url.prototype = inherit(LocationHashbangUrl.prototype); - -function locationGetter(property) { - return function() { - return this[property]; - }; -} - - -function locationGetterSetter(property, preprocess) { - return function(value) { - if (isUndefined(value)) - return this[property]; - - this[property] = preprocess(value); - this.$$compose(); - - return this; - }; -} - - -/** - * @ngdoc object - * @name ng.$location - * - * @requires $browser - * @requires $sniffer - * @requires $rootElement - * - * @description - * The $location service parses the URL in the browser address bar (based on the - * {@link https://developer.mozilla.org/en/window.location window.location}) and makes the URL - * available to your application. Changes to the URL in the address bar are reflected into - * $location service and changes to $location are reflected into the browser address bar. - * - * **The $location service:** - * - * - Exposes the current URL in the browser address bar, so you can - * - Watch and observe the URL. - * - Change the URL. - * - Synchronizes the URL with the browser when the user - * - Changes the address bar. - * - Clicks the back or forward button (or clicks a History link). - * - Clicks on a link. - * - Represents the URL object as a set of methods (protocol, host, port, path, search, hash). - * - * For more information see {@link guide/dev_guide.services.$location Developer Guide: Angular - * Services: Using $location} - */ - -/** - * @ngdoc object - * @name ng.$locationProvider - * @description - * Use the `$locationProvider` to configure how the application deep linking paths are stored. - */ -function $LocationProvider(){ - var hashPrefix = '', - html5Mode = false; - - /** - * @ngdoc property - * @name ng.$locationProvider#hashPrefix - * @methodOf ng.$locationProvider - * @description - * @param {string=} prefix Prefix for hash part (containing path and search) - * @returns {*} current value if used as getter or itself (chaining) if used as setter - */ - this.hashPrefix = function(prefix) { - if (isDefined(prefix)) { - hashPrefix = prefix; - return this; - } else { - return hashPrefix; - } - }; - - /** - * @ngdoc property - * @name ng.$locationProvider#html5Mode - * @methodOf ng.$locationProvider - * @description - * @param {string=} mode Use HTML5 strategy if available. - * @returns {*} current value if used as getter or itself (chaining) if used as setter - */ - this.html5Mode = function(mode) { - if (isDefined(mode)) { - html5Mode = mode; - return this; - } else { - return html5Mode; - } - }; - - this.$get = ['$rootScope', '$browser', '$sniffer', '$rootElement', - function( $rootScope, $browser, $sniffer, $rootElement) { - var $location, - basePath, - pathPrefix, - initUrl = $browser.url(), - initUrlParts = matchUrl(initUrl), - appBaseUrl; - - if (html5Mode) { - basePath = $browser.baseHref() || '/'; - pathPrefix = pathPrefixFromBase(basePath); - appBaseUrl = - composeProtocolHostPort(initUrlParts.protocol, initUrlParts.host, initUrlParts.port) + - pathPrefix + '/'; - - if ($sniffer.history) { - $location = new LocationUrl( - convertToHtml5Url(initUrl, basePath, hashPrefix), - pathPrefix, appBaseUrl); - } else { - $location = new LocationHashbangInHtml5Url( - convertToHashbangUrl(initUrl, basePath, hashPrefix), - hashPrefix, appBaseUrl, basePath.substr(pathPrefix.length + 1)); - } - } else { - appBaseUrl = - composeProtocolHostPort(initUrlParts.protocol, initUrlParts.host, initUrlParts.port) + - (initUrlParts.path || '') + - (initUrlParts.search ? ('?' + initUrlParts.search) : '') + - '#' + hashPrefix + '/'; - - $location = new LocationHashbangUrl(initUrl, hashPrefix, appBaseUrl); - } - - $rootElement.bind('click', function(event) { - // TODO(vojta): rewrite link when opening in new tab/window (in legacy browser) - // currently we open nice url link and redirect then - - if (event.ctrlKey || event.metaKey || event.which == 2) return; - - var elm = jqLite(event.target); - - // traverse the DOM up to find first A tag - while (lowercase(elm[0].nodeName) !== 'a') { - // ignore rewriting if no A tag (reached root element, or no parent - removed from document) - if (elm[0] === $rootElement[0] || !(elm = elm.parent())[0]) return; - } - - var absHref = elm.prop('href'), - rewrittenUrl = $location.$$rewriteAppUrl(absHref); - - if (absHref && !elm.attr('target') && rewrittenUrl) { - // update location manually - $location.$$parse(rewrittenUrl); - $rootScope.$apply(); - event.preventDefault(); - // hack to work around FF6 bug 684208 when scenario runner clicks on links - window.angular['ff-684208-preventDefault'] = true; - } - }); - - - // rewrite hashbang url <> html5 url - if ($location.absUrl() != initUrl) { - $browser.url(https://github.com/$location.absUrl(), true); - } - - // update $location when $browser url changes - $browser.onUrlChange(function(newUrl) { - if ($location.absUrl() != newUrl) { - $rootScope.$evalAsync(function() { - var oldUrl = $location.absUrl(); - - $location.$$parse(newUrl); - afterLocationChange(oldUrl); - }); - if (!$rootScope.$$phase) $rootScope.$digest(); - } - }); - - // update browser - var changeCounter = 0; - $rootScope.$watch(function $locationWatch() { - var oldUrl = $browser.url(); - var currentReplace = $location.$$replace; - - if (!changeCounter || oldUrl != $location.absUrl()) { - changeCounter++; - $rootScope.$evalAsync(function() { - if ($rootScope.$broadcast('$locationChangeStart', $location.absUrl(), oldUrl). - defaultPrevented) { - $location.$$parse(oldUrl); - } else { - $browser.url(https://github.com/$location.absUrl(), currentReplace); - afterLocationChange(oldUrl); - } - }); - } - $location.$$replace = false; - - return changeCounter; - }); - - return $location; - - function afterLocationChange(oldUrl) { - $rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl); - } -}]; -} - -/** - * @ngdoc object - * @name ng.$log - * @requires $window - * - * @description - * Simple service for logging. Default implementation writes the message - * into the browser's console (if present). - * - * The main purpose of this service is to simplify debugging and troubleshooting. - * - * @example - - - function LogCtrl($scope, $log) { - $scope.$log = $log; - $scope.message = 'Hello World!'; - } - - - - Reload this page with open console, enter text and hit the log button... - Message: - - log - warn - info - error - - - - */ - -function $LogProvider(){ - this.$get = ['$window', function($window){ - return { - /** - * @ngdoc method - * @name ng.$log#log - * @methodOf ng.$log - * - * @description - * Write a log message - */ - log: consoleLog('log'), - - /** - * @ngdoc method - * @name ng.$log#warn - * @methodOf ng.$log - * - * @description - * Write a warning message - */ - warn: consoleLog('warn'), - - /** - * @ngdoc method - * @name ng.$log#info - * @methodOf ng.$log - * - * @description - * Write an information message - */ - info: consoleLog('info'), - - /** - * @ngdoc method - * @name ng.$log#error - * @methodOf ng.$log - * - * @description - * Write an error message - */ - error: consoleLog('error') - }; - - function formatError(arg) { - if (arg instanceof Error) { - if (arg.stack) { - arg = (arg.message && arg.stack.indexOf(arg.message) === -1) - ? 'Error: ' + arg.message + '\n' + arg.stack - : arg.stack; - } else if (arg.sourceURL) { - arg = arg.message + '\n' + arg.sourceURL + ':' + arg.line; - } - } - return arg; - } - - function consoleLog(type) { - var console = $window.console || {}, - logFn = console[type] || console.log || noop; - - if (logFn.apply) { - return function() { - var args = []; - forEach(arguments, function(arg) { - args.push(formatError(arg)); - }); - return logFn.apply(console, args); - }; - } - - // we are IE which either doesn't have window.console => this is noop and we do nothing, - // or we are IE where console.log doesn't have apply so we log at least first 2 args - return function(arg1, arg2) { - logFn(arg1, arg2); - } - } - }]; -} - -var OPERATORS = { - 'null':function(){return null;}, - 'true':function(){return true;}, - 'false':function(){return false;}, - undefined:noop, - '+':function(self, locals, a,b){ - a=a(self, locals); b=b(self, locals); - if (isDefined(a)) { - if (isDefined(b)) { - return a + b; - } - return a; - } - return isDefined(b)?b:undefined;}, - '-':function(self, locals, a,b){a=a(self, locals); b=b(self, locals); return (isDefined(a)?a:0)-(isDefined(b)?b:0);}, - '*':function(self, locals, a,b){return a(self, locals)*b(self, locals);}, - '/':function(self, locals, a,b){return a(self, locals)/b(self, locals);}, - '%':function(self, locals, a,b){return a(self, locals)%b(self, locals);}, - '^':function(self, locals, a,b){return a(self, locals)^b(self, locals);}, - '=':noop, - '==':function(self, locals, a,b){return a(self, locals)==b(self, locals);}, - '!=':function(self, locals, a,b){return a(self, locals)!=b(self, locals);}, - '<':function(self, locals, a,b){return a(self, locals)':function(self, locals, a,b){return a(self, locals)>b(self, locals);}, - '<=':function(self, locals, a,b){return a(self, locals)<=b(self, locals);}, - '>=':function(self, locals, a,b){return a(self, locals)>=b(self, locals);}, - '&&':function(self, locals, a,b){return a(self, locals)&&b(self, locals);}, - '||':function(self, locals, a,b){return a(self, locals)||b(self, locals);}, - '&':function(self, locals, a,b){return a(self, locals)&b(self, locals);}, -// '|':function(self, locals, a,b){return a|b;}, - '|':function(self, locals, a,b){return b(self, locals)(self, locals, a(self, locals));}, - '!':function(self, locals, a){return !a(self, locals);} -}; -var ESCAPE = {"n":"\n", "f":"\f", "r":"\r", "t":"\t", "v":"\v", "'":"'", '"':'"'}; - -function lex(text, csp){ - var tokens = [], - token, - index = 0, - json = [], - ch, - lastCh = ':'; // can start regexp - - while (index < text.length) { - ch = text.charAt(index); - if (is('"\'')) { - readString(ch); - } else if (isNumber(ch) || is('.') && isNumber(peek())) { - readNumber(); - } else if (isIdent(ch)) { - readIdent(); - // identifiers can only be if the preceding char was a { or , - if (was('{,') && json[0]=='{' && - (token=tokens[tokens.length-1])) { - token.json = token.text.indexOf('.') == -1; - } - } else if (is('(){}[].,;:')) { - tokens.push({ - index:index, - text:ch, - json:(was(':[,') && is('{[')) || is('}]:,') - }); - if (is('{[')) json.unshift(ch); - if (is('}]')) json.shift(); - index++; - } else if (isWhitespace(ch)) { - index++; - continue; - } else { - var ch2 = ch + peek(), - fn = OPERATORS[ch], - fn2 = OPERATORS[ch2]; - if (fn2) { - tokens.push({index:index, text:ch2, fn:fn2}); - index += 2; - } else if (fn) { - tokens.push({index:index, text:ch, fn:fn, json: was('[,:') && is('+-')}); - index += 1; - } else { - throwError("Unexpected next character ", index, index+1); - } - } - lastCh = ch; - } - return tokens; - - function is(chars) { - return chars.indexOf(ch) != -1; - } - - function was(chars) { - return chars.indexOf(lastCh) != -1; - } - - function peek() { - return index + 1 < text.length ? text.charAt(index + 1) : false; - } - function isNumber(ch) { - return '0' <= ch && ch <= '9'; - } - function isWhitespace(ch) { - return ch == ' ' || ch == '\r' || ch == '\t' || - ch == '\n' || ch == '\v' || ch == '\u00A0'; // IE treats non-breaking space as \u00A0 - } - function isIdent(ch) { - return 'a' <= ch && ch <= 'z' || - 'A' <= ch && ch <= 'Z' || - '_' == ch || ch == '$'; - } - function isExpOperator(ch) { - return ch == '-' || ch == '+' || isNumber(ch); - } - - function throwError(error, start, end) { - end = end || index; - throw Error("Lexer Error: " + error + " at column" + - (isDefined(start) - ? "s " + start + "-" + index + " [" + text.substring(start, end) + "]" - : " " + end) + - " in expression [" + text + "]."); - } - - function readNumber() { - var number = ""; - var start = index; - while (index < text.length) { - var ch = lowercase(text.charAt(index)); - if (ch == '.' || isNumber(ch)) { - number += ch; - } else { - var peekCh = peek(); - if (ch == 'e' && isExpOperator(peekCh)) { - number += ch; - } else if (isExpOperator(ch) && - peekCh && isNumber(peekCh) && - number.charAt(number.length - 1) == 'e') { - number += ch; - } else if (isExpOperator(ch) && - (!peekCh || !isNumber(peekCh)) && - number.charAt(number.length - 1) == 'e') { - throwError('Invalid exponent'); - } else { - break; - } - } - index++; - } - number = 1 * number; - tokens.push({index:start, text:number, json:true, - fn:function() {return number;}}); - } - function readIdent() { - var ident = "", - start = index, - lastDot, peekIndex, methodName; - - while (index < text.length) { - var ch = text.charAt(index); - if (ch == '.' || isIdent(ch) || isNumber(ch)) { - if (ch == '.') lastDot = index; - ident += ch; - } else { - break; - } - index++; - } - - //check if this is not a method invocation and if it is back out to last dot - if (lastDot) { - peekIndex = index; - while(peekIndex < text.length) { - var ch = text.charAt(peekIndex); - if (ch == '(') { - methodName = ident.substr(lastDot - start + 1); - ident = ident.substr(0, lastDot - start); - index = peekIndex; - break; - } - if(isWhitespace(ch)) { - peekIndex++; - } else { - break; - } - } - } - - - var token = { - index:start, - text:ident - }; - - if (OPERATORS.hasOwnProperty(ident)) { - token.fn = token.json = OPERATORS[ident]; - } else { - var getter = getterFn(ident, csp); - token.fn = extend(function(self, locals) { - return (getter(self, locals)); - }, { - assign: function(self, value) { - return setter(self, ident, value); - } - }); - } - - tokens.push(token); - - if (methodName) { - tokens.push({ - index:lastDot, - text: '.', - json: false - }); - tokens.push({ - index: lastDot + 1, - text: methodName, - json: false - }); - } - } - - function readString(quote) { - var start = index; - index++; - var string = ""; - var rawString = quote; - var escape = false; - while (index < text.length) { - var ch = text.charAt(index); - rawString += ch; - if (escape) { - if (ch == 'u') { - var hex = text.substring(index + 1, index + 5); - if (!hex.match(/[\da-f]{4}/i)) - throwError( "Invalid unicode escape [\\u" + hex + "]"); - index += 4; - string += String.fromCharCode(parseInt(hex, 16)); - } else { - var rep = ESCAPE[ch]; - if (rep) { - string += rep; - } else { - string += ch; - } - } - escape = false; - } else if (ch == '\\') { - escape = true; - } else if (ch == quote) { - index++; - tokens.push({ - index:start, - text:rawString, - string:string, - json:true, - fn:function() { return string; } - }); - return; - } else { - string += ch; - } - index++; - } - throwError("Unterminated quote", start); - } -} - -///////////////////////////////////////// - -function parser(text, json, $filter, csp){ - var ZERO = valueFn(0), - value, - tokens = lex(text, csp), - assignment = _assignment, - functionCall = _functionCall, - fieldAccess = _fieldAccess, - objectIndex = _objectIndex, - filterChain = _filterChain; - - if(json){ - // The extra level of aliasing is here, just in case the lexer misses something, so that - // we prevent any accidental execution in JSON. - assignment = logicalOR; - functionCall = - fieldAccess = - objectIndex = - filterChain = - function() { throwError("is not valid json", {text:text, index:0}); }; - value = primary(); - } else { - value = statements(); - } - if (tokens.length !== 0) { - throwError("is an unexpected token", tokens[0]); - } - return value; - - /////////////////////////////////// - function throwError(msg, token) { - throw Error("Syntax Error: Token '" + token.text + - "' " + msg + " at column " + - (token.index + 1) + " of the expression [" + - text + "] starting at [" + text.substring(token.index) + "]."); - } - - function peekToken() { - if (tokens.length === 0) - throw Error("Unexpected end of expression: " + text); - return tokens[0]; - } - - function peek(e1, e2, e3, e4) { - if (tokens.length > 0) { - var token = tokens[0]; - var t = token.text; - if (t==e1 || t==e2 || t==e3 || t==e4 || - (!e1 && !e2 && !e3 && !e4)) { - return token; - } - } - return false; - } - - function expect(e1, e2, e3, e4){ - var token = peek(e1, e2, e3, e4); - if (token) { - if (json && !token.json) { - throwError("is not valid json", token); - } - tokens.shift(); - return token; - } - return false; - } - - function consume(e1){ - if (!expect(e1)) { - throwError("is unexpected, expecting [" + e1 + "]", peek()); - } - } - - function unaryFn(fn, right) { - return function(self, locals) { - return fn(self, locals, right); - }; - } - - function binaryFn(left, fn, right) { - return function(self, locals) { - return fn(self, locals, left, right); - }; - } - - function statements() { - var statements = []; - while(true) { - if (tokens.length > 0 && !peek('}', ')', ';', ']')) - statements.push(filterChain()); - if (!expect(';')) { - // optimize for the common case where there is only one statement. - // TODO(size): maybe we should not support multiple statements? - return statements.length == 1 - ? statements[0] - : function(self, locals){ - var value; - for ( var i = 0; i < statements.length; i++) { - var statement = statements[i]; - if (statement) - value = statement(self, locals); - } - return value; - }; - } - } - } - - function _filterChain() { - var left = expression(); - var token; - while(true) { - if ((token = expect('|'))) { - left = binaryFn(left, token.fn, filter()); - } else { - return left; - } - } - } - - function filter() { - var token = expect(); - var fn = $filter(token.text); - var argsFn = []; - while(true) { - if ((token = expect(':'))) { - argsFn.push(expression()); - } else { - var fnInvoke = function(self, locals, input){ - var args = [input]; - for ( var i = 0; i < argsFn.length; i++) { - args.push(argsFn[i](self, locals)); - } - return fn.apply(self, args); - }; - return function() { - return fnInvoke; - }; - } - } - } - - function expression() { - return assignment(); - } - - function _assignment() { - var left = logicalOR(); - var right; - var token; - if ((token = expect('='))) { - if (!left.assign) { - throwError("implies assignment but [" + - text.substring(0, token.index) + "] can not be assigned to", token); - } - right = logicalOR(); - return function(self, locals){ - return left.assign(self, right(self, locals), locals); - }; - } else { - return left; - } - } - - function logicalOR() { - var left = logicalAND(); - var token; - while(true) { - if ((token = expect('||'))) { - left = binaryFn(left, token.fn, logicalAND()); - } else { - return left; - } - } - } - - function logicalAND() { - var left = equality(); - var token; - if ((token = expect('&&'))) { - left = binaryFn(left, token.fn, logicalAND()); - } - return left; - } - - function equality() { - var left = relational(); - var token; - if ((token = expect('==','!='))) { - left = binaryFn(left, token.fn, equality()); - } - return left; - } - - function relational() { - var left = additive(); - var token; - if ((token = expect('<', '>', '<=', '>='))) { - left = binaryFn(left, token.fn, relational()); - } - return left; - } - - function additive() { - var left = multiplicative(); - var token; - while ((token = expect('+','-'))) { - left = binaryFn(left, token.fn, multiplicative()); - } - return left; - } - - function multiplicative() { - var left = unary(); - var token; - while ((token = expect('*','/','%'))) { - left = binaryFn(left, token.fn, unary()); - } - return left; - } - - function unary() { - var token; - if (expect('+')) { - return primary(); - } else if ((token = expect('-'))) { - return binaryFn(ZERO, token.fn, unary()); - } else if ((token = expect('!'))) { - return unaryFn(token.fn, unary()); - } else { - return primary(); - } - } - - - function primary() { - var primary; - if (expect('(')) { - primary = filterChain(); - consume(')'); - } else if (expect('[')) { - primary = arrayDeclaration(); - } else if (expect('{')) { - primary = object(); - } else { - var token = expect(); - primary = token.fn; - if (!primary) { - throwError("not a primary expression", token); - } - } - - var next, context; - while ((next = expect('(', '[', '.'))) { - if (next.text === '(') { - primary = functionCall(primary, context); - context = null; - } else if (next.text === '[') { - context = primary; - primary = objectIndex(primary); - } else if (next.text === '.') { - context = primary; - primary = fieldAccess(primary); - } else { - throwError("IMPOSSIBLE"); - } - } - return primary; - } - - function _fieldAccess(object) { - var field = expect().text; - var getter = getterFn(field, csp); - return extend( - function(self, locals) { - return getter(object(self, locals), locals); - }, - { - assign:function(self, value, locals) { - return setter(object(self, locals), field, value); - } - } - ); - } - - function _objectIndex(obj) { - var indexFn = expression(); - consume(']'); - return extend( - function(self, locals){ - var o = obj(self, locals), - i = indexFn(self, locals), - v, p; - - if (!o) return undefined; - v = o[i]; - if (v && v.then) { - p = v; - if (!('$$v' in v)) { - p.$$v = undefined; - p.then(function(val) { p.$$v = val; }); - } - v = v.$$v; - } - return v; - }, { - assign:function(self, value, locals){ - return obj(self, locals)[indexFn(self, locals)] = value; - } - }); - } - - function _functionCall(fn, contextGetter) { - var argsFn = []; - if (peekToken().text != ')') { - do { - argsFn.push(expression()); - } while (expect(',')); - } - consume(')'); - return function(self, locals){ - var args = [], - context = contextGetter ? contextGetter(self, locals) : self; - - for ( var i = 0; i < argsFn.length; i++) { - args.push(argsFn[i](self, locals)); - } - var fnPtr = fn(self, locals) || noop; - // IE stupidity! - return fnPtr.apply - ? fnPtr.apply(context, args) - : fnPtr(args[0], args[1], args[2], args[3], args[4]); - }; - } - - // This is used with json array declaration - function arrayDeclaration () { - var elementFns = []; - if (peekToken().text != ']') { - do { - elementFns.push(expression()); - } while (expect(',')); - } - consume(']'); - return function(self, locals){ - var array = []; - for ( var i = 0; i < elementFns.length; i++) { - array.push(elementFns[i](self, locals)); - } - return array; - }; - } - - function object () { - var keyValues = []; - if (peekToken().text != '}') { - do { - var token = expect(), - key = token.string || token.text; - consume(":"); - var value = expression(); - keyValues.push({key:key, value:value}); - } while (expect(',')); - } - consume('}'); - return function(self, locals){ - var object = {}; - for ( var i = 0; i < keyValues.length; i++) { - var keyValue = keyValues[i]; - var value = keyValue.value(self, locals); - object[keyValue.key] = value; - } - return object; - }; - } -} - -////////////////////////////////////////////////// -// Parser helper functions -////////////////////////////////////////////////// - -function setter(obj, path, setValue) { - var element = path.split('.'); - for (var i = 0; element.length > 1; i++) { - var key = element.shift(); - var propertyObj = obj[key]; - if (!propertyObj) { - propertyObj = {}; - obj[key] = propertyObj; - } - obj = propertyObj; - } - obj[element.shift()] = setValue; - return setValue; -} - -/** - * Return the value accesible from the object by path. Any undefined traversals are ignored - * @param {Object} obj starting object - * @param {string} path path to traverse - * @param {boolean=true} bindFnToScope - * @returns value as accesbile by path - */ -//TODO(misko): this function needs to be removed -function getter(obj, path, bindFnToScope) { - if (!path) return obj; - var keys = path.split('.'); - var key; - var lastInstance = obj; - var len = keys.length; - - for (var i = 0; i < len; i++) { - key = keys[i]; - if (obj) { - obj = (lastInstance = obj)[key]; - } - } - if (!bindFnToScope && isFunction(obj)) { - return bind(lastInstance, obj); - } - return obj; -} - -var getterFnCache = {}; - -/** - * Implementation of the "Black Hole" variant from: - * - http://jsperf.com/angularjs-parse-getter/4 - * - http://jsperf.com/path-evaluation-simplified/7 - */ -function cspSafeGetterFn(key0, key1, key2, key3, key4) { - return function(scope, locals) { - var pathVal = (locals && locals.hasOwnProperty(key0)) ? locals : scope, - promise; - - if (pathVal === null || pathVal === undefined) return pathVal; - - pathVal = pathVal[key0]; - if (pathVal && pathVal.then) { - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - if (!key1 || pathVal === null || pathVal === undefined) return pathVal; - - pathVal = pathVal[key1]; - if (pathVal && pathVal.then) { - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - if (!key2 || pathVal === null || pathVal === undefined) return pathVal; - - pathVal = pathVal[key2]; - if (pathVal && pathVal.then) { - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - if (!key3 || pathVal === null || pathVal === undefined) return pathVal; - - pathVal = pathVal[key3]; - if (pathVal && pathVal.then) { - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - if (!key4 || pathVal === null || pathVal === undefined) return pathVal; - - pathVal = pathVal[key4]; - if (pathVal && pathVal.then) { - if (!("$$v" in pathVal)) { - promise = pathVal; - promise.$$v = undefined; - promise.then(function(val) { promise.$$v = val; }); - } - pathVal = pathVal.$$v; - } - return pathVal; - }; -}; - -function getterFn(path, csp) { - if (getterFnCache.hasOwnProperty(path)) { - return getterFnCache[path]; - } - - var pathKeys = path.split('.'), - pathKeysLength = pathKeys.length, - fn; - - if (csp) { - fn = (pathKeysLength < 6) - ? cspSafeGetterFn(pathKeys[0], pathKeys[1], pathKeys[2], pathKeys[3], pathKeys[4]) - : function(scope, locals) { - var i = 0, val - do { - val = cspSafeGetterFn( - pathKeys[i++], pathKeys[i++], pathKeys[i++], pathKeys[i++], pathKeys[i++] - )(scope, locals); - - locals = undefined; // clear after first iteration - scope = val; - } while (i < pathKeysLength); - return val; - } - } else { - var code = 'var l, fn, p;\n'; - forEach(pathKeys, function(key, index) { - code += 'if(s === null || s === undefined) return s;\n' + - 'l=s;\n' + - 's='+ (index - // we simply dereference 's' on any .dot notation - ? 's' - // but if we are first then we check locals first, and if so read it first - : '((k&&k.hasOwnProperty("' + key + '"))?k:s)') + '["' + key + '"]' + ';\n' + - 'if (s && s.then) {\n' + - ' if (!("$$v" in s)) {\n' + - ' p=s;\n' + - ' p.$$v = undefined;\n' + - ' p.then(function(v) {p.$$v=v;});\n' + - '}\n' + - ' s=s.$$v\n' + - '}\n'; - }); - code += 'return s;'; - fn = Function('s', 'k', code); // s=scope, k=locals - fn.toString = function() { return code; }; - } - - return getterFnCache[path] = fn; -} - -/////////////////////////////////// - -/** - * @ngdoc function - * @name ng.$parse - * @function - * - * @description - * - * Converts Angular {@link guide/expression expression} into a function. - * - * - * var getter = $parse('user.name'); - * var setter = getter.assign; - * var context = {user:{name:'angular'}}; - * var locals = {user:{name:'local'}}; - * - * expect(getter(context)).toEqual('angular'); - * setter(context, 'newValue'); - * expect(context.user.name).toEqual('newValue'); - * expect(getter(context, locals)).toEqual('local'); - * - * - * - * @param {string} expression String expression to compile. - * @returns {function(context, locals)} a function which represents the compiled expression: - * - * * `context` – `{object}` – an object against which any expressions embedded in the strings - * are evaluated against (tipically a scope object). - * * `locals` – `{object=}` – local variables context object, useful for overriding values in - * `context`. - * - * The return function also has an `assign` property, if the expression is assignable, which - * allows one to set values to expressions. - * - */ -function $ParseProvider() { - var cache = {}; - this.$get = ['$filter', '$sniffer', function($filter, $sniffer) { - return function(exp) { - switch(typeof exp) { - case 'string': - return cache.hasOwnProperty(exp) - ? cache[exp] - : cache[exp] = parser(exp, false, $filter, $sniffer.csp); - case 'function': - return exp; - default: - return noop; - } - }; - }]; -} - -/** - * @ngdoc service - * @name ng.$q - * @requires $rootScope - * - * @description - * A promise/deferred implementation inspired by [Kris Kowal's Q](https://github.com/kriskowal/q). - * - * [The CommonJS Promise proposal](http://wiki.commonjs.org/wiki/Promises) describes a promise as an - * interface for interacting with an object that represents the result of an action that is - * performed asynchronously, and may or may not be finished at any given point in time. - * - * From the perspective of dealing with error handling, deferred and promise APIs are to - * asynchronous programming what `try`, `catch` and `throw` keywords are to synchronous programming. - * - * - * // for the purpose of this example let's assume that variables `$q` and `scope` are - * // available in the current lexical scope (they could have been injected or passed in). - * - * function asyncGreet(name) { - * var deferred = $q.defer(); - * - * setTimeout(function() { - * // since this fn executes async in a future turn of the event loop, we need to wrap - * // our code into an $apply call so that the model changes are properly observed. - * scope.$apply(function() { - * if (okToGreet(name)) { - * deferred.resolve('Hello, ' + name + '!'); - * } else { - * deferred.reject('Greeting ' + name + ' is not allowed.'); - * } - * }); - * }, 1000); - * - * return deferred.promise; - * } - * - * var promise = asyncGreet('Robin Hood'); - * promise.then(function(greeting) { - * alert('Success: ' + greeting); - * }, function(reason) { - * alert('Failed: ' + reason); - * }); - * - * - * At first it might not be obvious why this extra complexity is worth the trouble. The payoff - * comes in the way of - * [guarantees that promise and deferred APIs make](https://github.com/kriskowal/uncommonjs/blob/master/promises/specification.md). - * - * Additionally the promise api allows for composition that is very hard to do with the - * traditional callback ([CPS](http://en.wikipedia.org/wiki/Continuation-passing_style)) approach. - * For more on this please see the [Q documentation](https://github.com/kriskowal/q) especially the - * section on serial or parallel joining of promises. - * - * - * # The Deferred API - * - * A new instance of deferred is constructed by calling `$q.defer()`. - * - * The purpose of the deferred object is to expose the associated Promise instance as well as APIs - * that can be used for signaling the successful or unsuccessful completion of the task. - * - * **Methods** - * - * - `resolve(value)` – resolves the derived promise with the `value`. If the value is a rejection - * constructed via `$q.reject`, the promise will be rejected instead. - * - `reject(reason)` – rejects the derived promise with the `reason`. This is equivalent to - * resolving it with a rejection constructed via `$q.reject`. - * - * **Properties** - * - * - promise – `{Promise}` – promise object associated with this deferred. - * - * - * # The Promise API - * - * A new promise instance is created when a deferred instance is created and can be retrieved by - * calling `deferred.promise`. - * - * The purpose of the promise object is to allow for interested parties to get access to the result - * of the deferred task when it completes. - * - * **Methods** - * - * - `then(successCallback, errorCallback)` – regardless of when the promise was or will be resolved - * or rejected calls one of the success or error callbacks asynchronously as soon as the result - * is available. The callbacks are called with a single argument the result or rejection reason. - * - * This method *returns a new promise* which is resolved or rejected via the return value of the - * `successCallback` or `errorCallback`. - * - * - * # Chaining promises - * - * Because calling `then` api of a promise returns a new derived promise, it is easily possible - * to create a chain of promises: - * - * - * promiseB = promiseA.then(function(result) { - * return result + 1; - * }); - * - * // promiseB will be resolved immediately after promiseA is resolved and its value will be - * // the result of promiseA incremented by 1 - * - * - * It is possible to create chains of any length and since a promise can be resolved with another - * promise (which will defer its resolution further), it is possible to pause/defer resolution of - * the promises at any point in the chain. This makes it possible to implement powerful apis like - * $http's response interceptors. - * - * - * # Differences between Kris Kowal's Q and $q - * - * There are three main differences: - * - * - $q is integrated with the {@link ng.$rootScope.Scope} Scope model observation - * mechanism in angular, which means faster propagation of resolution or rejection into your - * models and avoiding unnecessary browser repaints, which would result in flickering UI. - * - $q promises are recognized by the templating engine in angular, which means that in templates - * you can treat promises attached to a scope as if they were the resulting values. - * - Q has many more features that $q, but that comes at a cost of bytes. $q is tiny, but contains - * all the important functionality needed for common async tasks. - * - * # Testing - * - * - * it('should simulate promise', inject(function($q, $rootScope) { - * var deferred = $q.defer(); - * var promise = deferred.promise; - * var resolvedValue; - * - * promise.then(function(value) { resolvedValue = value; }); - * expect(resolvedValue).toBeUndefined(); - * - * // Simulate resolving of promise - * deferred.resolve(123); - * // Note that the 'then' function does not get called synchronously. - * // This is because we want the promise API to always be async, whether or not - * // it got called synchronously or asynchronously. - * expect(resolvedValue).toBeUndefined(); - * - * // Propagate promise resolution to 'then' functions using $apply(). - * $rootScope.$apply(); - * expect(resolvedValue).toEqual(123); - * }); - * - */ -function $QProvider() { - - this.$get = ['$rootScope', '$exceptionHandler', function($rootScope, $exceptionHandler) { - return qFactory(function(callback) { - $rootScope.$evalAsync(callback); - }, $exceptionHandler); - }]; -} - - -/** - * Constructs a promise manager. - * - * @param {function(function)} nextTick Function for executing functions in the next turn. - * @param {function(...*)} exceptionHandler Function into which unexpected exceptions are passed for - * debugging purposes. - * @returns {object} Promise manager. - */ -function qFactory(nextTick, exceptionHandler) { - - /** - * @ngdoc - * @name ng.$q#defer - * @methodOf ng.$q - * @description - * Creates a `Deferred` object which represents a task which will finish in the future. - * - * @returns {Deferred} Returns a new instance of deferred. - */ - var defer = function() { - var pending = [], - value, deferred; - - deferred = { - - resolve: function(val) { - if (pending) { - var callbacks = pending; - pending = undefined; - value = ref(val); - - if (callbacks.length) { - nextTick(function() { - var callback; - for (var i = 0, ii = callbacks.length; i < ii; i++) { - callback = callbacks[i]; - value.then(callback[0], callback[1]); - } - }); - } - } - }, - - - reject: function(reason) { - deferred.resolve(reject(reason)); - }, - - - promise: { - then: function(callback, errback) { - var result = defer(); - - var wrappedCallback = function(value) { - try { - result.resolve((callback || defaultCallback)(value)); - } catch(e) { - exceptionHandler(e); - result.reject(e); - } - }; - - var wrappedErrback = function(reason) { - try { - result.resolve((errback || defaultErrback)(reason)); - } catch(e) { - exceptionHandler(e); - result.reject(e); - } - }; - - if (pending) { - pending.push([wrappedCallback, wrappedErrback]); - } else { - value.then(wrappedCallback, wrappedErrback); - } - - return result.promise; - } - } - }; - - return deferred; - }; - - - var ref = function(value) { - if (value && value.then) return value; - return { - then: function(callback) { - var result = defer(); - nextTick(function() { - result.resolve(callback(value)); - }); - return result.promise; - } - }; - }; - - - /** - * @ngdoc - * @name ng.$q#reject - * @methodOf ng.$q - * @description - * Creates a promise that is resolved as rejected with the specified `reason`. This api should be - * used to forward rejection in a chain of promises. If you are dealing with the last promise in - * a promise chain, you don't need to worry about it. - * - * When comparing deferreds/promises to the familiar behavior of try/catch/throw, think of - * `reject` as the `throw` keyword in JavaScript. This also means that if you "catch" an error via - * a promise error callback and you want to forward the error to the promise derived from the - * current promise, you have to "rethrow" the error by returning a rejection constructed via - * `reject`. - * - * - * promiseB = promiseA.then(function(result) { - * // success: do something and resolve promiseB - * // with the old or a new result - * return result; - * }, function(reason) { - * // error: handle the error if possible and - * // resolve promiseB with newPromiseOrValue, - * // otherwise forward the rejection to promiseB - * if (canHandle(reason)) { - * // handle the error and recover - * return newPromiseOrValue; - * } - * return $q.reject(reason); - * }); - * - * - * @param {*} reason Constant, message, exception or an object representing the rejection reason. - * @returns {Promise} Returns a promise that was already resolved as rejected with the `reason`. - */ - var reject = function(reason) { - return { - then: function(callback, errback) { - var result = defer(); - nextTick(function() { - result.resolve((errback || defaultErrback)(reason)); - }); - return result.promise; - } - }; - }; - - - /** - * @ngdoc - * @name ng.$q#when - * @methodOf ng.$q - * @description - * Wraps an object that might be a value or a (3rd party) then-able promise into a $q promise. - * This is useful when you are dealing with an object that might or might not be a promise, or if - * the promise comes from a source that can't be trusted. - * - * @param {*} value Value or a promise - * @returns {Promise} Returns a single promise that will be resolved with an array of values, - * each value corresponding to the promise at the same index in the `promises` array. If any of - * the promises is resolved with a rejection, this resulting promise will be resolved with the - * same rejection. - */ - var when = function(value, callback, errback) { - var result = defer(), - done; - - var wrappedCallback = function(value) { - try { - return (callback || defaultCallback)(value); - } catch (e) { - exceptionHandler(e); - return reject(e); - } - }; - - var wrappedErrback = function(reason) { - try { - return (errback || defaultErrback)(reason); - } catch (e) { - exceptionHandler(e); - return reject(e); - } - }; - - nextTick(function() { - ref(value).then(function(value) { - if (done) return; - done = true; - result.resolve(ref(value).then(wrappedCallback, wrappedErrback)); - }, function(reason) { - if (done) return; - done = true; - result.resolve(wrappedErrback(reason)); - }); - }); - - return result.promise; - }; - - - function defaultCallback(value) { - return value; - } - - - function defaultErrback(reason) { - return reject(reason); - } - - - /** - * @ngdoc - * @name ng.$q#all - * @methodOf ng.$q - * @description - * Combines multiple promises into a single promise that is resolved when all of the input - * promises are resolved. - * - * @param {Array.} promises An array of promises. - * @returns {Promise} Returns a single promise that will be resolved with an array of values, - * each value corresponding to the promise at the same index in the `promises` array. If any of - * the promises is resolved with a rejection, this resulting promise will be resolved with the - * same rejection. - */ - function all(promises) { - var deferred = defer(), - counter = promises.length, - results = []; - - if (counter) { - forEach(promises, function(promise, index) { - ref(promise).then(function(value) { - if (index in results) return; - results[index] = value; - if (!(--counter)) deferred.resolve(results); - }, function(reason) { - if (index in results) return; - deferred.reject(reason); - }); - }); - } else { - deferred.resolve(results); - } - - return deferred.promise; - } - - return { - defer: defer, - reject: reject, - when: when, - all: all - }; -} - -/** - * @ngdoc object - * @name ng.$routeProvider - * @function - * - * @description - * - * Used for configuring routes. See {@link ng.$route $route} for an example. - */ -function $RouteProvider(){ - var routes = {}; - - /** - * @ngdoc method - * @name ng.$routeProvider#when - * @methodOf ng.$routeProvider - * - * @param {string} path Route path (matched against `$location.path`). If `$location.path` - * contains redundant trailing slash or is missing one, the route will still match and the - * `$location.path` will be updated to add or drop the trailing slash to exactly match the - * route definition. - * - * `path` can contain named groups starting with a colon (`:name`). All characters up to the - * next slash are matched and stored in `$routeParams` under the given `name` when the route - * matches. - * - * @param {Object} route Mapping information to be assigned to `$route.current` on route - * match. - * - * Object properties: - * - * - `controller` – `{(string|function()=}` – Controller fn that should be associated with newly - * created scope or the name of a {@link angular.Module#controller registered controller} - * if passed as a string. - * - `template` – `{string=}` – html template as a string that should be used by - * {@link ng.directive:ngView ngView} or - * {@link ng.directive:ngInclude ngInclude} directives. - * this property takes precedence over `templateUrl`. - * - `templateUrl` – `{string=}` – path to an html template that should be used by - * {@link ng.directive:ngView ngView}. - * - `resolve` - `{Object.=}` - An optional map of dependencies which should - * be injected into the controller. If any of these dependencies are promises, they will be - * resolved and converted to a value before the controller is instantiated and the - * `$routeChangeSuccess` event is fired. The map object is: - * - * - `key` – `{string}`: a name of a dependency to be injected into the controller. - * - `factory` - `{string|function}`: If `string` then it is an alias for a service. - * Otherwise if function, then it is {@link api/AUTO.$injector#invoke injected} - * and the return value is treated as the dependency. If the result is a promise, it is resolved - * before its value is injected into the controller. - * - * - `redirectTo` – {(string|function())=} – value to update - * {@link ng.$location $location} path with and trigger route redirection. - * - * If `redirectTo` is a function, it will be called with the following parameters: - * - * - `{Object.}` - route parameters extracted from the current - * `$location.path()` by applying the current route templateUrl. - * - `{string}` - current `$location.path()` - * - `{Object}` - current `$location.search()` - * - * The custom `redirectTo` function is expected to return a string which will be used - * to update `$location.path()` and `$location.search()`. - * - * - `[reloadOnSearch=true]` - {boolean=} - reload route when only $location.search() - * changes. - * - * If the option is set to `false` and url in the browser changes, then - * `$routeUpdate` event is broadcasted on the root scope. - * - * @returns {Object} self - * - * @description - * Adds a new route definition to the `$route` service. - */ - this.when = function(path, route) { - routes[path] = extend({reloadOnSearch: true}, route); - - // create redirection for trailing slashes - if (path) { - var redirectPath = (path[path.length-1] == '/') - ? path.substr(0, path.length-1) - : path +'/'; - - routes[redirectPath] = {redirectTo: path}; - } - - return this; - }; - - /** - * @ngdoc method - * @name ng.$routeProvider#otherwise - * @methodOf ng.$routeProvider - * - * @description - * Sets route definition that will be used on route change when no other route definition - * is matched. - * - * @param {Object} params Mapping information to be assigned to `$route.current`. - * @returns {Object} self - */ - this.otherwise = function(params) { - this.when(null, params); - return this; - }; - - - this.$get = ['$rootScope', '$location', '$routeParams', '$q', '$injector', '$http', '$templateCache', - function( $rootScope, $location, $routeParams, $q, $injector, $http, $templateCache) { - - /** - * @ngdoc object - * @name ng.$route - * @requires $location - * @requires $routeParams - * - * @property {Object} current Reference to the current route definition. - * The route definition contains: - * - * - `controller`: The controller constructor as define in route definition. - * - `locals`: A map of locals which is used by {@link ng.$controller $controller} service for - * controller instantiation. The `locals` contain - * the resolved values of the `resolve` map. Additionally the `locals` also contain: - * - * - `$scope` - The current route scope. - * - `$template` - The current route template HTML. - * - * @property {Array.} routes Array of all configured routes. - * - * @description - * Is used for deep-linking URLs to controllers and views (HTML partials). - * It watches `$location.url()` and tries to map the path to an existing route definition. - * - * You can define routes through {@link ng.$routeProvider $routeProvider}'s API. - * - * The `$route` service is typically used in conjunction with {@link ng.directive:ngView ngView} - * directive and the {@link ng.$routeParams $routeParams} service. - * - * @example - This example shows how changing the URL hash causes the `$route` to match a route against the - URL, and the `ngView` pulls in the partial. - - Note that this example is using {@link ng.directive:script inlined templates} - to get it working on jsfiddle as well. - - - - - Choose: - Moby | - Moby: Ch1 | - Gatsby | - Gatsby: Ch4 | - Scarlet Letter - - - - - $location.path() = {{$location.path()}} - $route.current.templateUrl = {{$route.current.templateUrl}} - $route.current.params = {{$route.current.params}} - $route.current.scope.name = {{$route.current.scope.name}} - $routeParams = {{$routeParams}} - - - - - controller: {{name}} - Book Id: {{params.bookId}} - - - - controller: {{name}} - Book Id: {{params.bookId}} - Chapter Id: {{params.chapterId}} - - - - angular.module('ngView', [], function($routeProvider, $locationProvider) { - $routeProvider.when('/Book/:bookId', { - templateUrl: 'book.html', - controller: BookCntl, - resolve: { - // I will cause a 1 second delay - delay: function($q, $timeout) { - var delay = $q.defer(); - $timeout(delay.resolve, 1000); - return delay.promise; - } - } - }); - $routeProvider.when('/Book/:bookId/ch/:chapterId', { - templateUrl: 'chapter.html', - controller: ChapterCntl - }); - - // configure html5 to get links working on jsfiddle - $locationProvider.html5Mode(true); - }); - - function MainCntl($scope, $route, $routeParams, $location) { - $scope.$route = $route; - $scope.$location = $location; - $scope.$routeParams = $routeParams; - } - - function BookCntl($scope, $routeParams) { - $scope.name = "BookCntl"; - $scope.params = $routeParams; - } - - function ChapterCntl($scope, $routeParams) { - $scope.name = "ChapterCntl"; - $scope.params = $routeParams; - } - - - - it('should load and compile correct template', function() { - element('a:contains("Moby: Ch1")').click(); - var content = element('.doc-example-live [ng-view]').text(); - expect(content).toMatch(/controller\: ChapterCntl/); - expect(content).toMatch(/Book Id\: Moby/); - expect(content).toMatch(/Chapter Id\: 1/); - - element('a:contains("Scarlet")').click(); - sleep(2); // promises are not part of scenario waiting - content = element('.doc-example-live [ng-view]').text(); - expect(content).toMatch(/controller\: BookCntl/); - expect(content).toMatch(/Book Id\: Scarlet/); - }); - - - */ - - /** - * @ngdoc event - * @name ng.$route#$routeChangeStart - * @eventOf ng.$route - * @eventType broadcast on root scope - * @description - * Broadcasted before a route change. At this point the route services starts - * resolving all of the dependencies needed for the route change to occurs. - * Typically this involves fetching the view template as well as any dependencies - * defined in `resolve` route property. Once all of the dependencies are resolved - * `$routeChangeSuccess` is fired. - * - * @param {Route} next Future route information. - * @param {Route} current Current route information. - */ - - /** - * @ngdoc event - * @name ng.$route#$routeChangeSuccess - * @eventOf ng.$route - * @eventType broadcast on root scope - * @description - * Broadcasted after a route dependencies are resolved. - * {@link ng.directive:ngView ngView} listens for the directive - * to instantiate the controller and render the view. - * - * @param {Route} current Current route information. - * @param {Route} previous Previous route information. - */ - - /** - * @ngdoc event - * @name ng.$route#$routeChangeError - * @eventOf ng.$route - * @eventType broadcast on root scope - * @description - * Broadcasted if any of the resolve promises are rejected. - * - * @param {Route} current Current route information. - * @param {Route} previous Previous route information. - * @param {Route} rejection Rejection of the promise. Usually the error of the failed promise. - */ - - /** - * @ngdoc event - * @name ng.$route#$routeUpdate - * @eventOf ng.$route - * @eventType broadcast on root scope - * @description - * - * The `reloadOnSearch` property has been set to false, and we are reusing the same - * instance of the Controller. - */ - - var forceReload = false, - $route = { - routes: routes, - - /** - * @ngdoc method - * @name ng.$route#reload - * @methodOf ng.$route - * - * @description - * Causes `$route` service to reload the current route even if - * {@link ng.$location $location} hasn't changed. - * - * As a result of that, {@link ng.directive:ngView ngView} - * creates new scope, reinstantiates the controller. - */ - reload: function() { - forceReload = true; - $rootScope.$evalAsync(updateRoute); - } - }; - - $rootScope.$on('$locationChangeSuccess', updateRoute); - - return $route; - - ///////////////////////////////////////////////////// - - /** - * @param on {string} current url - * @param when {string} route when template to match the url against - * @return {?Object} - */ - function switchRouteMatcher(on, when) { - // TODO(i): this code is convoluted and inefficient, we should construct the route matching - // regex only once and then reuse it - - // Escape regexp special characters. - when = '^' + when.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&") + '$'; - var regex = '', - params = [], - dst = {}; - - var re = /:(\w+)/g, - paramMatch, - lastMatchedIndex = 0; - - while ((paramMatch = re.exec(when)) !== null) { - // Find each :param in `when` and replace it with a capturing group. - // Append all other sections of when unchanged. - regex += when.slice(lastMatchedIndex, paramMatch.index); - regex += '([^\\/]*)'; - params.push(paramMatch[1]); - lastMatchedIndex = re.lastIndex; - } - // Append trailing path part. - regex += when.substr(lastMatchedIndex); - - var match = on.match(new RegExp(regex)); - if (match) { - forEach(params, function(name, index) { - dst[name] = match[index + 1]; - }); - } - return match ? dst : null; - } - - function updateRoute() { - var next = parseRoute(), - last = $route.current; - - if (next && last && next.$route === last.$route - && equals(next.pathParams, last.pathParams) && !next.reloadOnSearch && !forceReload) { - last.params = next.params; - copy(last.params, $routeParams); - $rootScope.$broadcast('$routeUpdate', last); - } else if (next || last) { - forceReload = false; - $rootScope.$broadcast('$routeChangeStart', next, last); - $route.current = next; - if (next) { - if (next.redirectTo) { - if (isString(next.redirectTo)) { - $location.path(interpolate(next.redirectTo, next.params)).search(next.params) - .replace(); - } else { - $location.url(https://github.com/next.redirectTo(next.pathParams,%20$location.path(), $location.search())) - .replace(); - } - } - } - - $q.when(next). - then(function() { - if (next) { - var keys = [], - values = [], - template; - - forEach(next.resolve || {}, function(value, key) { - keys.push(key); - values.push(isString(value) ? $injector.get(value) : $injector.invoke(value)); - }); - if (isDefined(template = next.template)) { - } else if (isDefined(template = next.templateUrl)) { - template = $http.get(template, {cache: $templateCache}). - then(function(response) { return response.data; }); - } - if (isDefined(template)) { - keys.push('$template'); - values.push(template); - } - return $q.all(values).then(function(values) { - var locals = {}; - forEach(values, function(value, index) { - locals[keys[index]] = value; - }); - return locals; - }); - } - }). - // after route change - then(function(locals) { - if (next == $route.current) { - if (next) { - next.locals = locals; - copy(next.params, $routeParams); - } - $rootScope.$broadcast('$routeChangeSuccess', next, last); - } - }, function(error) { - if (next == $route.current) { - $rootScope.$broadcast('$routeChangeError', next, last, error); - } - }); - } - } - - - /** - * @returns the current active route, by matching it against the URL - */ - function parseRoute() { - // Match a route - var params, match; - forEach(routes, function(route, path) { - if (!match && (params = switchRouteMatcher($location.path(), path))) { - match = inherit(route, { - params: extend({}, $location.search(), params), - pathParams: params}); - match.$route = route; - } - }); - // No route matched; fallback to "otherwise" route - return match || routes[null] && inherit(routes[null], {params: {}, pathParams:{}}); - } - - /** - * @returns interpolation of the redirect path with the parametrs - */ - function interpolate(string, params) { - var result = []; - forEach((string||'').split(':'), function(segment, i) { - if (i == 0) { - result.push(segment); - } else { - var segmentMatch = segment.match(/(\w+)(.*)/); - var key = segmentMatch[1]; - result.push(params[key]); - result.push(segmentMatch[2] || ''); - delete params[key]; - } - }); - return result.join(''); - } - }]; -} - -/** - * @ngdoc object - * @name ng.$routeParams - * @requires $route - * - * @description - * Current set of route parameters. The route parameters are a combination of the - * {@link ng.$location $location} `search()`, and `path()`. The `path` parameters - * are extracted when the {@link ng.$route $route} path is matched. - * - * In case of parameter name collision, `path` params take precedence over `search` params. - * - * The service guarantees that the identity of the `$routeParams` object will remain unchanged - * (but its properties will likely change) even when a route change occurs. - * - * @example - * - * // Given: - * // URL: http://server.com/index.html#/Chapter/1/Section/2?search=moby - * // Route: /Chapter/:chapterId/Section/:sectionId - * // - * // Then - * $routeParams ==> {chapterId:1, sectionId:2, search:'moby'} - * - */ -function $RouteParamsProvider() { - this.$get = valueFn({}); -} - -/** - * DESIGN NOTES - * - * The design decisions behind the scope ware heavily favored for speed and memory consumption. - * - * The typical use of scope is to watch the expressions, which most of the time return the same - * value as last time so we optimize the operation. - * - * Closures construction is expensive from speed as well as memory: - * - no closures, instead ups prototypical inheritance for API - * - Internal state needs to be stored on scope directly, which means that private state is - * exposed as $$____ properties - * - * Loop operations are optimized by using while(count--) { ... } - * - this means that in order to keep the same order of execution as addition we have to add - * items to the array at the begging (shift) instead of at the end (push) - * - * Child scopes are created and removed often - * - Using array would be slow since inserts in meddle are expensive so we use linked list - * - * There are few watches then a lot of observers. This is why you don't want the observer to be - * implemented in the same way as watch. Watch requires return of initialization function which - * are expensive to construct. - */ - - -/** - * @ngdoc object - * @name ng.$rootScopeProvider - * @description - * - * Provider for the $rootScope service. - */ - -/** - * @ngdoc function - * @name ng.$rootScopeProvider#digestTtl - * @methodOf ng.$rootScopeProvider - * @description - * - * Sets the number of digest iteration the scope should attempt to execute before giving up and - * assuming that the model is unstable. - * - * The current default is 10 iterations. - * - * @param {number} limit The number of digest iterations. - */ - - -/** - * @ngdoc object - * @name ng.$rootScope - * @description - * - * Every application has a single root {@link ng.$rootScope.Scope scope}. - * All other scopes are child scopes of the root scope. Scopes provide mechanism for watching the model and provide - * event processing life-cycle. See {@link guide/scope developer guide on scopes}. - */ -function $RootScopeProvider(){ - var TTL = 10; - - this.digestTtl = function(value) { - if (arguments.length) { - TTL = value; - } - return TTL; - }; - - this.$get = ['$injector', '$exceptionHandler', '$parse', - function( $injector, $exceptionHandler, $parse) { - - /** - * @ngdoc function - * @name ng.$rootScope.Scope - * - * @description - * A root scope can be retrieved using the {@link ng.$rootScope $rootScope} key from the - * {@link AUTO.$injector $injector}. Child scopes are created using the - * {@link ng.$rootScope.Scope#$new $new()} method. (Most scopes are created automatically when - * compiled HTML template is executed.) - * - * Here is a simple scope snippet to show how you can interact with the scope. - * - angular.injector(['ng']).invoke(function($rootScope) { - var scope = $rootScope.$new(); - scope.salutation = 'Hello'; - scope.name = 'World'; - - expect(scope.greeting).toEqual(undefined); - - scope.$watch('name', function() { - scope.greeting = scope.salutation + ' ' + scope.name + '!'; - }); // initialize the watch - - expect(scope.greeting).toEqual(undefined); - scope.name = 'Misko'; - // still old value, since watches have not been called yet - expect(scope.greeting).toEqual(undefined); - - scope.$digest(); // fire all the watches - expect(scope.greeting).toEqual('Hello Misko!'); - }); - * - * - * # Inheritance - * A scope can inherit from a parent scope, as in this example: - * - var parent = $rootScope; - var child = parent.$new(); - - parent.salutation = "Hello"; - child.name = "World"; - expect(child.salutation).toEqual('Hello'); - - child.salutation = "Welcome"; - expect(child.salutation).toEqual('Welcome'); - expect(parent.salutation).toEqual('Hello'); - * - * - * - * @param {Object.=} providers Map of service factory which need to be provided - * for the current scope. Defaults to {@link ng}. - * @param {Object.=} instanceCache Provides pre-instantiated services which should - * append/override services provided by `providers`. This is handy when unit-testing and having - * the need to override a default service. - * @returns {Object} Newly created scope. - * - */ - function Scope() { - this.$id = nextUid(); - this.$$phase = this.$parent = this.$$watchers = - this.$$nextSibling = this.$$prevSibling = - this.$$childHead = this.$$childTail = null; - this['this'] = this.$root = this; - this.$$destroyed = false; - this.$$asyncQueue = []; - this.$$listeners = {}; - this.$$isolateBindings = {}; - } - - /** - * @ngdoc property - * @name ng.$rootScope.Scope#$id - * @propertyOf ng.$rootScope.Scope - * @returns {number} Unique scope ID (monotonically increasing alphanumeric sequence) useful for - * debugging. - */ - - - Scope.prototype = { - /** - * @ngdoc function - * @name ng.$rootScope.Scope#$new - * @methodOf ng.$rootScope.Scope - * @function - * - * @description - * Creates a new child {@link ng.$rootScope.Scope scope}. - * - * The parent scope will propagate the {@link ng.$rootScope.Scope#$digest $digest()} and - * {@link ng.$rootScope.Scope#$digest $digest()} events. The scope can be removed from the scope - * hierarchy using {@link ng.$rootScope.Scope#$destroy $destroy()}. - * - * {@link ng.$rootScope.Scope#$destroy $destroy()} must be called on a scope when it is desired for - * the scope and its child scopes to be permanently detached from the parent and thus stop - * participating in model change detection and listener notification by invoking. - * - * @param {boolean} isolate if true then the scope does not prototypically inherit from the - * parent scope. The scope is isolated, as it can not see parent scope properties. - * When creating widgets it is useful for the widget to not accidentally read parent - * state. - * - * @returns {Object} The newly created child scope. - * - */ - $new: function(isolate) { - var Child, - child; - - if (isFunction(isolate)) { - // TODO: remove at some point - throw Error('API-CHANGE: Use $controller to instantiate controllers.'); - } - if (isolate) { - child = new Scope(); - child.$root = this.$root; - } else { - Child = function() {}; // should be anonymous; This is so that when the minifier munges - // the name it does not become random set of chars. These will then show up as class - // name in the debugger. - Child.prototype = this; - child = new Child(); - child.$id = nextUid(); - } - child['this'] = child; - child.$$listeners = {}; - child.$parent = this; - child.$$asyncQueue = []; - child.$$watchers = child.$$nextSibling = child.$$childHead = child.$$childTail = null; - child.$$prevSibling = this.$$childTail; - if (this.$$childHead) { - this.$$childTail.$$nextSibling = child; - this.$$childTail = child; - } else { - this.$$childHead = this.$$childTail = child; - } - return child; - }, - - /** - * @ngdoc function - * @name ng.$rootScope.Scope#$watch - * @methodOf ng.$rootScope.Scope - * @function - * - * @description - * Registers a `listener` callback to be executed whenever the `watchExpression` changes. - * - * - The `watchExpression` is called on every call to {@link ng.$rootScope.Scope#$digest $digest()} and - * should return the value which will be watched. (Since {@link ng.$rootScope.Scope#$digest $digest()} - * reruns when it detects changes the `watchExpression` can execute multiple times per - * {@link ng.$rootScope.Scope#$digest $digest()} and should be idempotent.) - * - The `listener` is called only when the value from the current `watchExpression` and the - * previous call to `watchExpression` are not equal (with the exception of the initial run, - * see below). The inequality is determined according to - * {@link angular.equals} function. To save the value of the object for later comparison, the - * {@link angular.copy} function is used. It also means that watching complex options will - * have adverse memory and performance implications. - * - The watch `listener` may change the model, which may trigger other `listener`s to fire. This - * is achieved by rerunning the watchers until no changes are detected. The rerun iteration - * limit is 10 to prevent an infinite loop deadlock. - * - * - * If you want to be notified whenever {@link ng.$rootScope.Scope#$digest $digest} is called, - * you can register a `watchExpression` function with no `listener`. (Since `watchExpression` - * can execute multiple times per {@link ng.$rootScope.Scope#$digest $digest} cycle when a change is - * detected, be prepared for multiple calls to your listener.) - * - * After a watcher is registered with the scope, the `listener` fn is called asynchronously - * (via {@link ng.$rootScope.Scope#$evalAsync $evalAsync}) to initialize the - * watcher. In rare cases, this is undesirable because the listener is called when the result - * of `watchExpression` didn't change. To detect this scenario within the `listener` fn, you - * can compare the `newVal` and `oldVal`. If these two values are identical (`===`) then the - * listener was called due to initialization. - * - * - * # Example - * - // let's assume that scope was dependency injected as the $rootScope - var scope = $rootScope; - scope.name = 'misko'; - scope.counter = 0; - - expect(scope.counter).toEqual(0); - scope.$watch('name', function(newValue, oldValue) { scope.counter = scope.counter + 1; }); - expect(scope.counter).toEqual(0); - - scope.$digest(); - // no variable change - expect(scope.counter).toEqual(0); - - scope.name = 'adam'; - scope.$digest(); - expect(scope.counter).toEqual(1); - * - * - * - * - * @param {(function()|string)} watchExpression Expression that is evaluated on each - * {@link ng.$rootScope.Scope#$digest $digest} cycle. A change in the return value triggers a - * call to the `listener`. - * - * - `string`: Evaluated as {@link guide/expression expression} - * - `function(scope)`: called with current `scope` as a parameter. - * @param {(function()|string)=} listener Callback called whenever the return value of - * the `watchExpression` changes. - * - * - `string`: Evaluated as {@link guide/expression expression} - * - `function(newValue, oldValue, scope)`: called with current and previous values as parameters. - * - * @param {boolean=} objectEquality Compare object for equality rather than for reference. - * @returns {function()} Returns a deregistration function for this listener. - */ - $watch: function(watchExp, listener, objectEquality) { - var scope = this, - get = compileToFn(watchExp, 'watch'), - array = scope.$$watchers, - watcher = { - fn: listener, - last: initWatchVal, - get: get, - exp: watchExp, - eq: !!objectEquality - }; - - // in the case user pass string, we need to compile it, do we really need this ? - if (!isFunction(listener)) { - var listenFn = compileToFn(listener || noop, 'listener'); - watcher.fn = function(newVal, oldVal, scope) {listenFn(scope);}; - } - - if (!array) { - array = scope.$$watchers = []; - } - // we use unshift since we use a while loop in $digest for speed. - // the while loop reads in reverse order. - array.unshift(watcher); - - return function() { - arrayRemove(array, watcher); - }; - }, - - /** - * @ngdoc function - * @name ng.$rootScope.Scope#$digest - * @methodOf ng.$rootScope.Scope - * @function - * - * @description - * Process all of the {@link ng.$rootScope.Scope#$watch watchers} of the current scope and its children. - * Because a {@link ng.$rootScope.Scope#$watch watcher}'s listener can change the model, the - * `$digest()` keeps calling the {@link ng.$rootScope.Scope#$watch watchers} until no more listeners are - * firing. This means that it is possible to get into an infinite loop. This function will throw - * `'Maximum iteration limit exceeded.'` if the number of iterations exceeds 10. - * - * Usually you don't call `$digest()` directly in - * {@link ng.directive:ngController controllers} or in - * {@link ng.$compileProvider#directive directives}. - * Instead a call to {@link ng.$rootScope.Scope#$apply $apply()} (typically from within a - * {@link ng.$compileProvider#directive directives}) will force a `$digest()`. - * - * If you want to be notified whenever `$digest()` is called, - * you can register a `watchExpression` function with {@link ng.$rootScope.Scope#$watch $watch()} - * with no `listener`. - * - * You may have a need to call `$digest()` from within unit-tests, to simulate the scope - * life-cycle. - * - * # Example - * - var scope = ...; - scope.name = 'misko'; - scope.counter = 0; - - expect(scope.counter).toEqual(0); - scope.$watch('name', function(newValue, oldValue) { - scope.counter = scope.counter + 1; - }); - expect(scope.counter).toEqual(0); - - scope.$digest(); - // no variable change - expect(scope.counter).toEqual(0); - - scope.name = 'adam'; - scope.$digest(); - expect(scope.counter).toEqual(1); - * - * - */ - $digest: function() { - var watch, value, last, - watchers, - asyncQueue, - length, - dirty, ttl = TTL, - next, current, target = this, - watchLog = [], - logIdx, logMsg; - - beginPhase('$digest'); - - do { - dirty = false; - current = target; - do { - asyncQueue = current.$$asyncQueue; - while(asyncQueue.length) { - try { - current.$eval(asyncQueue.shift()); - } catch (e) { - $exceptionHandler(e); - } - } - if ((watchers = current.$$watchers)) { - // process our watches - length = watchers.length; - while (length--) { - try { - watch = watchers[length]; - // Most common watches are on primitives, in which case we can short - // circuit it with === operator, only when === fails do we use .equals - if ((value = watch.get(current)) !== (last = watch.last) && - !(watch.eq - ? equals(value, last) - : (typeof value == 'number' && typeof last == 'number' - && isNaN(value) && isNaN(last)))) { - dirty = true; - watch.last = watch.eq ? copy(value) : value; - watch.fn(value, ((last === initWatchVal) ? value : last), current); - if (ttl < 5) { - logIdx = 4 - ttl; - if (!watchLog[logIdx]) watchLog[logIdx] = []; - logMsg = (isFunction(watch.exp)) - ? 'fn: ' + (watch.exp.name || watch.exp.toString()) - : watch.exp; - logMsg += '; newVal: ' + toJson(value) + '; oldVal: ' + toJson(last); - watchLog[logIdx].push(logMsg); - } - } - } catch (e) { - $exceptionHandler(e); - } - } - } - - // Insanity Warning: scope depth-first traversal - // yes, this code is a bit crazy, but it works and we have tests to prove it! - // this piece should be kept in sync with the traversal in $broadcast - if (!(next = (current.$$childHead || (current !== target && current.$$nextSibling)))) { - while(current !== target && !(next = current.$$nextSibling)) { - current = current.$parent; - } - } - } while ((current = next)); - - if(dirty && !(ttl--)) { - clearPhase(); - throw Error(TTL + ' $digest() iterations reached. Aborting!\n' + - 'Watchers fired in the last 5 iterations: ' + toJson(watchLog)); - } - } while (dirty || asyncQueue.length); - - clearPhase(); - }, - - - /** - * @ngdoc event - * @name ng.$rootScope.Scope#$destroy - * @eventOf ng.$rootScope.Scope - * @eventType broadcast on scope being destroyed - * - * @description - * Broadcasted when a scope and its children are being destroyed. - */ - - /** - * @ngdoc function - * @name ng.$rootScope.Scope#$destroy - * @methodOf ng.$rootScope.Scope - * @function - * - * @description - * Removes the current scope (and all of its children) from the parent scope. Removal implies - * that calls to {@link ng.$rootScope.Scope#$digest $digest()} will no longer - * propagate to the current scope and its children. Removal also implies that the current - * scope is eligible for garbage collection. - * - * The `$destroy()` is usually used by directives such as - * {@link ng.directive:ngRepeat ngRepeat} for managing the - * unrolling of the loop. - * - * Just before a scope is destroyed a `$destroy` event is broadcasted on this scope. - * Application code can register a `$destroy` event handler that will give it chance to - * perform any necessary cleanup. - */ - $destroy: function() { - // we can't destroy the root scope or a scope that has been already destroyed - if ($rootScope == this || this.$$destroyed) return; - var parent = this.$parent; - - this.$broadcast('$destroy'); - this.$$destroyed = true; - - if (parent.$$childHead == this) parent.$$childHead = this.$$nextSibling; - if (parent.$$childTail == this) parent.$$childTail = this.$$prevSibling; - if (this.$$prevSibling) this.$$prevSibling.$$nextSibling = this.$$nextSibling; - if (this.$$nextSibling) this.$$nextSibling.$$prevSibling = this.$$prevSibling; - - // This is bogus code that works around Chrome's GC leak - // see: https://github.com/angular/angular.js/issues/1313#issuecomment-10378451 - this.$parent = this.$$nextSibling = this.$$prevSibling = this.$$childHead = - this.$$childTail = null; - }, - - /** - * @ngdoc function - * @name ng.$rootScope.Scope#$eval - * @methodOf ng.$rootScope.Scope - * @function - * - * @description - * Executes the `expression` on the current scope returning the result. Any exceptions in the - * expression are propagated (uncaught). This is useful when evaluating Angular expressions. - * - * # Example - * - var scope = ng.$rootScope.Scope(); - scope.a = 1; - scope.b = 2; - - expect(scope.$eval('a+b')).toEqual(3); - expect(scope.$eval(function(scope){ return scope.a + scope.b; })).toEqual(3); - * - * - * @param {(string|function())=} expression An angular expression to be executed. - * - * - `string`: execute using the rules as defined in {@link guide/expression expression}. - * - `function(scope)`: execute the function with the current `scope` parameter. - * - * @returns {*} The result of evaluating the expression. - */ - $eval: function(expr, locals) { - return $parse(expr)(this, locals); - }, - - /** - * @ngdoc function - * @name ng.$rootScope.Scope#$evalAsync - * @methodOf ng.$rootScope.Scope - * @function - * - * @description - * Executes the expression on the current scope at a later point in time. - * - * The `$evalAsync` makes no guarantees as to when the `expression` will be executed, only that: - * - * - it will execute in the current script execution context (before any DOM rendering). - * - at least one {@link ng.$rootScope.Scope#$digest $digest cycle} will be performed after - * `expression` execution. - * - * Any exceptions from the execution of the expression are forwarded to the - * {@link ng.$exceptionHandler $exceptionHandler} service. - * - * @param {(string|function())=} expression An angular expression to be executed. - * - * - `string`: execute using the rules as defined in {@link guide/expression expression}. - * - `function(scope)`: execute the function with the current `scope` parameter. - * - */ - $evalAsync: function(expr) { - this.$$asyncQueue.push(expr); - }, - - /** - * @ngdoc function - * @name ng.$rootScope.Scope#$apply - * @methodOf ng.$rootScope.Scope - * @function - * - * @description - * `$apply()` is used to execute an expression in angular from outside of the angular framework. - * (For example from browser DOM events, setTimeout, XHR or third party libraries). - * Because we are calling into the angular framework we need to perform proper scope life-cycle - * of {@link ng.$exceptionHandler exception handling}, - * {@link ng.$rootScope.Scope#$digest executing watches}. - * - * ## Life cycle - * - * # Pseudo-Code of `$apply()` - * - function $apply(expr) { - try { - return $eval(expr); - } catch (e) { - $exceptionHandler(e); - } finally { - $root.$digest(); - } - } - * - * - * - * Scope's `$apply()` method transitions through the following stages: - * - * 1. The {@link guide/expression expression} is executed using the - * {@link ng.$rootScope.Scope#$eval $eval()} method. - * 2. Any exceptions from the execution of the expression are forwarded to the - * {@link ng.$exceptionHandler $exceptionHandler} service. - * 3. The {@link ng.$rootScope.Scope#$watch watch} listeners are fired immediately after the expression - * was executed using the {@link ng.$rootScope.Scope#$digest $digest()} method. - * - * - * @param {(string|function())=} exp An angular expression to be executed. - * - * - `string`: execute using the rules as defined in {@link guide/expression expression}. - * - `function(scope)`: execute the function with current `scope` parameter. - * - * @returns {*} The result of evaluating the expression. - */ - $apply: function(expr) { - try { - beginPhase('$apply'); - return this.$eval(expr); - } catch (e) { - $exceptionHandler(e); - } finally { - clearPhase(); - try { - $rootScope.$digest(); - } catch (e) { - $exceptionHandler(e); - throw e; - } - } - }, - - /** - * @ngdoc function - * @name ng.$rootScope.Scope#$on - * @methodOf ng.$rootScope.Scope - * @function - * - * @description - * Listens on events of a given type. See {@link ng.$rootScope.Scope#$emit $emit} for discussion of - * event life cycle. - * - * The event listener function format is: `function(event, args...)`. The `event` object - * passed into the listener has the following attributes: - * - * - `targetScope` - `{Scope}`: the scope on which the event was `$emit`-ed or `$broadcast`-ed. - * - `currentScope` - `{Scope}`: the current scope which is handling the event. - * - `name` - `{string}`: Name of the event. - * - `stopPropagation` - `{function=}`: calling `stopPropagation` function will cancel further event - * propagation (available only for events that were `$emit`-ed). - * - `preventDefault` - `{function}`: calling `preventDefault` sets `defaultPrevented` flag to true. - * - `defaultPrevented` - `{boolean}`: true if `preventDefault` was called. - * - * @param {string} name Event name to listen on. - * @param {function(event, args...)} listener Function to call when the event is emitted. - * @returns {function()} Returns a deregistration function for this listener. - */ - $on: function(name, listener) { - var namedListeners = this.$$listeners[name]; - if (!namedListeners) { - this.$$listeners[name] = namedListeners = []; - } - namedListeners.push(listener); - - return function() { - namedListeners[indexOf(namedListeners, listener)] = null; - }; - }, - - - /** - * @ngdoc function - * @name ng.$rootScope.Scope#$emit - * @methodOf ng.$rootScope.Scope - * @function - * - * @description - * Dispatches an event `name` upwards through the scope hierarchy notifying the - * registered {@link ng.$rootScope.Scope#$on} listeners. - * - * The event life cycle starts at the scope on which `$emit` was called. All - * {@link ng.$rootScope.Scope#$on listeners} listening for `name` event on this scope get notified. - * Afterwards, the event traverses upwards toward the root scope and calls all registered - * listeners along the way. The event will stop propagating if one of the listeners cancels it. - * - * Any exception emmited from the {@link ng.$rootScope.Scope#$on listeners} will be passed - * onto the {@link ng.$exceptionHandler $exceptionHandler} service. - * - * @param {string} name Event name to emit. - * @param {...*} args Optional set of arguments which will be passed onto the event listeners. - * @return {Object} Event object, see {@link ng.$rootScope.Scope#$on} - */ - $emit: function(name, args) { - var empty = [], - namedListeners, - scope = this, - stopPropagation = false, - event = { - name: name, - targetScope: scope, - stopPropagation: function() {stopPropagation = true;}, - preventDefault: function() { - event.defaultPrevented = true; - }, - defaultPrevented: false - }, - listenerArgs = concat([event], arguments, 1), - i, length; - - do { - namedListeners = scope.$$listeners[name] || empty; - event.currentScope = scope; - for (i=0, length=namedListeners.length; i 7), - hasEvent: function(event) { - // IE9 implements 'input' event it's so fubared that we rather pretend that it doesn't have - // it. In particular the event is not fired when backspace or delete key are pressed or - // when cut operation is performed. - if (event == 'input' && msie == 9) return false; - - if (isUndefined(eventSupport[event])) { - var divElm = $window.document.createElement('div'); - eventSupport[event] = 'on' + event in divElm; - } - - return eventSupport[event]; - }, - // TODO(i): currently there is no way to feature detect CSP without triggering alerts - csp: false - }; - }]; -} - -/** - * @ngdoc object - * @name ng.$window - * - * @description - * A reference to the browser's `window` object. While `window` - * is globally available in JavaScript, it causes testability problems, because - * it is a global variable. In angular we always refer to it through the - * `$window` service, so it may be overriden, removed or mocked for testing. - * - * All expressions are evaluated with respect to current scope so they don't - * suffer from window globality. - * - * @example - - - - ALERT - - - - - */ -function $WindowProvider(){ - this.$get = valueFn(window); -} - -/** - * Parse headers into key value object - * - * @param {string} headers Raw headers as a string - * @returns {Object} Parsed headers as key value object - */ -function parseHeaders(headers) { - var parsed = {}, key, val, i; - - if (!headers) return parsed; - - forEach(headers.split('\n'), function(line) { - i = line.indexOf(':'); - key = lowercase(trim(line.substr(0, i))); - val = trim(line.substr(i + 1)); - - if (key) { - if (parsed[key]) { - parsed[key] += ', ' + val; - } else { - parsed[key] = val; - } - } - }); - - return parsed; -} - - -/** - * Returns a function that provides access to parsed headers. - * - * Headers are lazy parsed when first requested. - * @see parseHeaders - * - * @param {(string|Object)} headers Headers to provide access to. - * @returns {function(string=)} Returns a getter function which if called with: - * - * - if called with single an argument returns a single header value or null - * - if called with no arguments returns an object containing all headers. - */ -function headersGetter(headers) { - var headersObj = isObject(headers) ? headers : undefined; - - return function(name) { - if (!headersObj) headersObj = parseHeaders(headers); - - if (name) { - return headersObj[lowercase(name)] || null; - } - - return headersObj; - }; -} - - -/** - * Chain all given functions - * - * This function is used for both request and response transforming - * - * @param {*} data Data to transform. - * @param {function(string=)} headers Http headers getter fn. - * @param {(function|Array.)} fns Function or an array of functions. - * @returns {*} Transformed data. - */ -function transformData(data, headers, fns) { - if (isFunction(fns)) - return fns(data, headers); - - forEach(fns, function(fn) { - data = fn(data, headers); - }); - - return data; -} - - -function isSuccess(status) { - return 200 <= status && status < 300; -} - - -function $HttpProvider() { - var JSON_START = /^\s*(\[|\{[^\{])/, - JSON_END = /[\}\]]\s*$/, - PROTECTION_PREFIX = /^\)\]\}',?\n/; - - var $config = this.defaults = { - // transform incoming response data - transformResponse: [function(data) { - if (isString(data)) { - // strip json vulnerability protection prefix - data = data.replace(PROTECTION_PREFIX, ''); - if (JSON_START.test(data) && JSON_END.test(data)) - data = fromJson(data, true); - } - return data; - }], - - // transform outgoing request data - transformRequest: [function(d) { - return isObject(d) && !isFile(d) ? toJson(d) : d; - }], - - // default headers - headers: { - common: { - 'Accept': 'application/json, text/plain, */*', - 'X-Requested-With': 'XMLHttpRequest' - }, - post: {'Content-Type': 'application/json;charset=utf-8'}, - put: {'Content-Type': 'application/json;charset=utf-8'} - } - }; - - var providerResponseInterceptors = this.responseInterceptors = []; - - this.$get = ['$httpBackend', '$browser', '$cacheFactory', '$rootScope', '$q', '$injector', - function($httpBackend, $browser, $cacheFactory, $rootScope, $q, $injector) { - - var defaultCache = $cacheFactory('$http'), - responseInterceptors = []; - - forEach(providerResponseInterceptors, function(interceptor) { - responseInterceptors.push( - isString(interceptor) - ? $injector.get(interceptor) - : $injector.invoke(interceptor) - ); - }); - - - /** - * @ngdoc function - * @name ng.$http - * @requires $httpBackend - * @requires $browser - * @requires $cacheFactory - * @requires $rootScope - * @requires $q - * @requires $injector - * - * @description - * The `$http` service is a core Angular service that facilitates communication with the remote - * HTTP servers via browser's {@link https://developer.mozilla.org/en/xmlhttprequest - * XMLHttpRequest} object or via {@link http://en.wikipedia.org/wiki/JSONP JSONP}. - * - * For unit testing applications that use `$http` service, see - * {@link ngMock.$httpBackend $httpBackend mock}. - * - * For a higher level of abstraction, please check out the {@link ngResource.$resource - * $resource} service. - * - * The $http API is based on the {@link ng.$q deferred/promise APIs} exposed by - * the $q service. While for simple usage patters this doesn't matter much, for advanced usage, - * it is important to familiarize yourself with these apis and guarantees they provide. - * - * - * # General usage - * The `$http` service is a function which takes a single argument — a configuration object — - * that is used to generate an http request and returns a {@link ng.$q promise} - * with two $http specific methods: `success` and `error`. - * - * - * $http({method: 'GET', url: '/someUrl'}). - * success(function(data, status, headers, config) { - * // this callback will be called asynchronously - * // when the response is available - * }). - * error(function(data, status, headers, config) { - * // called asynchronously if an error occurs - * // or server returns response with an error status. - * }); - * - * - * Since the returned value of calling the $http function is a Promise object, you can also use - * the `then` method to register callbacks, and these callbacks will receive a single argument – - * an object representing the response. See the api signature and type info below for more - * details. - * - * A response status code that falls in the [200, 300) range is considered a success status and - * will result in the success callback being called. Note that if the response is a redirect, - * XMLHttpRequest will transparently follow it, meaning that the error callback will not be - * called for such responses. - * - * # Shortcut methods - * - * Since all invocation of the $http service require definition of the http method and url and - * POST and PUT requests require response body/data to be provided as well, shortcut methods - * were created to simplify using the api: - * - * - * $http.get('/someUrl').success(successCallback); - * $http.post('/someUrl', data).success(successCallback); - * - * - * Complete list of shortcut methods: - * - * - {@link ng.$http#get $http.get} - * - {@link ng.$http#head $http.head} - * - {@link ng.$http#post $http.post} - * - {@link ng.$http#put $http.put} - * - {@link ng.$http#delete $http.delete} - * - {@link ng.$http#jsonp $http.jsonp} - * - * - * # Setting HTTP Headers - * - * The $http service will automatically add certain http headers to all requests. These defaults - * can be fully configured by accessing the `$httpProvider.defaults.headers` configuration - * object, which currently contains this default configuration: - * - * - `$httpProvider.defaults.headers.common` (headers that are common for all requests): - * - `Accept: application/json, text/plain, * / *` - * - `X-Requested-With: XMLHttpRequest` - * - `$httpProvider.defaults.headers.post`: (header defaults for HTTP POST requests) - * - `Content-Type: application/json` - * - `$httpProvider.defaults.headers.put` (header defaults for HTTP PUT requests) - * - `Content-Type: application/json` - * - * To add or overwrite these defaults, simply add or remove a property from this configuration - * objects. To add headers for an HTTP method other than POST or PUT, simply add a new object - * with name equal to the lower-cased http method name, e.g. - * `$httpProvider.defaults.headers.get['My-Header']='value'`. - * - * Additionally, the defaults can be set at runtime via the `$http.defaults` object in a similar - * fassion as described above. - * - * - * # Transforming Requests and Responses - * - * Both requests and responses can be transformed using transform functions. By default, Angular - * applies these transformations: - * - * Request transformations: - * - * - if the `data` property of the request config object contains an object, serialize it into - * JSON format. - * - * Response transformations: - * - * - if XSRF prefix is detected, strip it (see Security Considerations section below) - * - if json response is detected, deserialize it using a JSON parser - * - * To override these transformation locally, specify transform functions as `transformRequest` - * and/or `transformResponse` properties of the config object. To globally override the default - * transforms, override the `$httpProvider.defaults.transformRequest` and - * `$httpProvider.defaults.transformResponse` properties of the `$httpProvider`. - * - * - * # Caching - * - * To enable caching set the configuration property `cache` to `true`. When the cache is - * enabled, `$http` stores the response from the server in local cache. Next time the - * response is served from the cache without sending a request to the server. - * - * Note that even if the response is served from cache, delivery of the data is asynchronous in - * the same way that real requests are. - * - * If there are multiple GET requests for the same url that should be cached using the same - * cache, but the cache is not populated yet, only one request to the server will be made and - * the remaining requests will be fulfilled using the response for the first request. - * - * - * # Response interceptors - * - * Before you start creating interceptors, be sure to understand the - * {@link ng.$q $q and deferred/promise APIs}. - * - * For purposes of global error handling, authentication or any kind of synchronous or - * asynchronous preprocessing of received responses, it is desirable to be able to intercept - * responses for http requests before they are handed over to the application code that - * initiated these requests. The response interceptors leverage the {@link ng.$q - * promise apis} to fulfil this need for both synchronous and asynchronous preprocessing. - * - * The interceptors are service factories that are registered with the $httpProvider by - * adding them to the `$httpProvider.responseInterceptors` array. The factory is called and - * injected with dependencies (if specified) and returns the interceptor — a function that - * takes a {@link ng.$q promise} and returns the original or a new promise. - * - * - * // register the interceptor as a service - * $provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) { - * return function(promise) { - * return promise.then(function(response) { - * // do something on success - * }, function(response) { - * // do something on error - * if (canRecover(response)) { - * return responseOrNewPromise - * } - * return $q.reject(response); - * }); - * } - * }); - * - * $httpProvider.responseInterceptors.push('myHttpInterceptor'); - * - * - * // register the interceptor via an anonymous factory - * $httpProvider.responseInterceptors.push(function($q, dependency1, dependency2) { - * return function(promise) { - * // same as above - * } - * }); - * - * - * - * # Security Considerations - * - * When designing web applications, consider security threats from: - * - * - {@link http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx - * JSON Vulnerability} - * - {@link http://en.wikipedia.org/wiki/Cross-site_request_forgery XSRF} - * - * Both server and the client must cooperate in order to eliminate these threats. Angular comes - * pre-configured with strategies that address these issues, but for this to work backend server - * cooperation is required. - * - * ## JSON Vulnerability Protection - * - * A {@link http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx - * JSON Vulnerability} allows third party web-site to turn your JSON resource URL into - * {@link http://en.wikipedia.org/wiki/JSON#JSONP JSONP} request under some conditions. To - * counter this your server can prefix all JSON requests with following string `")]}',\n"`. - * Angular will automatically strip the prefix before processing it as JSON. - * - * For example if your server needs to return: - * - * ['one','two'] - * - * - * which is vulnerable to attack, your server can return: - * - * )]}', - * ['one','two'] - * - * - * Angular will strip the prefix, before processing the JSON. - * - * - * ## Cross Site Request Forgery (XSRF) Protection - * - * {@link http://en.wikipedia.org/wiki/Cross-site_request_forgery XSRF} is a technique by which - * an unauthorized site can gain your user's private data. Angular provides following mechanism - * to counter XSRF. When performing XHR requests, the $http service reads a token from a cookie - * called `XSRF-TOKEN` and sets it as the HTTP header `X-XSRF-TOKEN`. Since only JavaScript that - * runs on your domain could read the cookie, your server can be assured that the XHR came from - * JavaScript running on your domain. - * - * To take advantage of this, your server needs to set a token in a JavaScript readable session - * cookie called `XSRF-TOKEN` on first HTTP GET request. On subsequent non-GET requests the - * server can verify that the cookie matches `X-XSRF-TOKEN` HTTP header, and therefore be sure - * that only JavaScript running on your domain could have read the token. The token must be - * unique for each user and must be verifiable by the server (to prevent the JavaScript making - * up its own tokens). We recommend that the token is a digest of your site's authentication - * cookie with {@link http://en.wikipedia.org/wiki/Rainbow_table salt for added security}. - * - * - * @param {object} config Object describing the request to be made and how it should be - * processed. The object has following properties: - * - * - **method** – `{string}` – HTTP method (e.g. 'GET', 'POST', etc) - * - **url** – `{string}` – Absolute or relative URL of the resource that is being requested. - * - **params** – `{Object.}` – Map of strings or objects which will be turned to - * `?key1=value1&key2=value2` after the url. If the value is not a string, it will be JSONified. - * - **data** – `{string|Object}` – Data to be sent as the request message data. - * - **headers** – `{Object}` – Map of strings representing HTTP headers to send to the server. - * - **transformRequest** – `{function(data, headersGetter)|Array.}` – - * transform function or an array of such functions. The transform function takes the http - * request body and headers and returns its transformed (typically serialized) version. - * - **transformResponse** – `{function(data, headersGetter)|Array.}` – - * transform function or an array of such functions. The transform function takes the http - * response body and headers and returns its transformed (typically deserialized) version. - * - **cache** – `{boolean|Cache}` – If true, a default $http cache will be used to cache the - * GET request, otherwise if a cache instance built with - * {@link ng.$cacheFactory $cacheFactory}, this cache will be used for - * caching. - * - **timeout** – `{number}` – timeout in milliseconds. - * - **withCredentials** - `{boolean}` - whether to to set the `withCredentials` flag on the - * XHR object. See {@link https://developer.mozilla.org/en/http_access_control#section_5 - * requests with credentials} for more information. - * - * @returns {HttpPromise} Returns a {@link ng.$q promise} object with the - * standard `then` method and two http specific methods: `success` and `error`. The `then` - * method takes two arguments a success and an error callback which will be called with a - * response object. The `success` and `error` methods take a single argument - a function that - * will be called when the request succeeds or fails respectively. The arguments passed into - * these functions are destructured representation of the response object passed into the - * `then` method. The response object has these properties: - * - * - **data** – `{string|Object}` – The response body transformed with the transform functions. - * - **status** – `{number}` – HTTP status code of the response. - * - **headers** – `{function([headerName])}` – Header getter function. - * - **config** – `{Object}` – The configuration object that was used to generate the request. - * - * @property {Array.} pendingRequests Array of config objects for currently pending - * requests. This is primarily meant to be used for debugging purposes. - * - * - * @example - - - - - GET - JSONP - - - fetch - Sample GET - Sample JSONP - Invalid JSONP - http status code: {{status}} - http response data: {{data}} - - - - function FetchCtrl($scope, $http, $templateCache) { - $scope.method = 'GET'; - $scope.url = 'http-hello.html'; - - $scope.fetch = function() { - $scope.code = null; - $scope.response = null; - - $http({method: $scope.method, url: $scope.url, cache: $templateCache}). - success(function(data, status) { - $scope.status = status; - $scope.data = data; - }). - error(function(data, status) { - $scope.data = data || "Request failed"; - $scope.status = status; - }); - }; - - $scope.updateModel = function(method, url) { - $scope.method = method; - $scope.url = url; - }; - } - - - Hello, $http! - - - it('should make an xhr GET request', function() { - element(':button:contains("Sample GET")').click(); - element(':button:contains("fetch")').click(); - expect(binding('status')).toBe('200'); - expect(binding('data')).toMatch(/Hello, \$http!/); - }); - - it('should make a JSONP request to angularjs.org', function() { - element(':button:contains("Sample JSONP")').click(); - element(':button:contains("fetch")').click(); - expect(binding('status')).toBe('200'); - expect(binding('data')).toMatch(/Super Hero!/); - }); - - it('should make JSONP request to invalid URL and invoke the error handler', - function() { - element(':button:contains("Invalid JSONP")').click(); - element(':button:contains("fetch")').click(); - expect(binding('status')).toBe('0'); - expect(binding('data')).toBe('Request failed'); - }); - - - */ - function $http(config) { - config.method = uppercase(config.method); - - var reqTransformFn = config.transformRequest || $config.transformRequest, - respTransformFn = config.transformResponse || $config.transformResponse, - defHeaders = $config.headers, - reqHeaders = extend({'X-XSRF-TOKEN': $browser.cookies()['XSRF-TOKEN']}, - defHeaders.common, defHeaders[lowercase(config.method)], config.headers), - reqData = transformData(config.data, headersGetter(reqHeaders), reqTransformFn), - promise; - - // strip content-type if data is undefined - if (isUndefined(config.data)) { - delete reqHeaders['Content-Type']; - } - - // send request - promise = sendReq(config, reqData, reqHeaders); - - - // transform future response - promise = promise.then(transformResponse, transformResponse); - - // apply interceptors - forEach(responseInterceptors, function(interceptor) { - promise = interceptor(promise); - }); - - promise.success = function(fn) { - promise.then(function(response) { - fn(response.data, response.status, response.headers, config); - }); - return promise; - }; - - promise.error = function(fn) { - promise.then(null, function(response) { - fn(response.data, response.status, response.headers, config); - }); - return promise; - }; - - return promise; - - function transformResponse(response) { - // make a copy since the response must be cacheable - var resp = extend({}, response, { - data: transformData(response.data, response.headers, respTransformFn) - }); - return (isSuccess(response.status)) - ? resp - : $q.reject(resp); - } - } - - $http.pendingRequests = []; - - /** - * @ngdoc method - * @name ng.$http#get - * @methodOf ng.$http - * - * @description - * Shortcut method to perform `GET` request - * - * @param {string} url Relative or absolute URL specifying the destination of the request - * @param {Object=} config Optional configuration object - * @returns {HttpPromise} Future object - */ - - /** - * @ngdoc method - * @name ng.$http#delete - * @methodOf ng.$http - * - * @description - * Shortcut method to perform `DELETE` request - * - * @param {string} url Relative or absolute URL specifying the destination of the request - * @param {Object=} config Optional configuration object - * @returns {HttpPromise} Future object - */ - - /** - * @ngdoc method - * @name ng.$http#head - * @methodOf ng.$http - * - * @description - * Shortcut method to perform `HEAD` request - * - * @param {string} url Relative or absolute URL specifying the destination of the request - * @param {Object=} config Optional configuration object - * @returns {HttpPromise} Future object - */ - - /** - * @ngdoc method - * @name ng.$http#jsonp - * @methodOf ng.$http - * - * @description - * Shortcut method to perform `JSONP` request - * - * @param {string} url Relative or absolute URL specifying the destination of the request. - * Should contain `JSON_CALLBACK` string. - * @param {Object=} config Optional configuration object - * @returns {HttpPromise} Future object - */ - createShortMethods('get', 'delete', 'head', 'jsonp'); - - /** - * @ngdoc method - * @name ng.$http#post - * @methodOf ng.$http - * - * @description - * Shortcut method to perform `POST` request - * - * @param {string} url Relative or absolute URL specifying the destination of the request - * @param {*} data Request content - * @param {Object=} config Optional configuration object - * @returns {HttpPromise} Future object - */ - - /** - * @ngdoc method - * @name ng.$http#put - * @methodOf ng.$http - * - * @description - * Shortcut method to perform `PUT` request - * - * @param {string} url Relative or absolute URL specifying the destination of the request - * @param {*} data Request content - * @param {Object=} config Optional configuration object - * @returns {HttpPromise} Future object - */ - createShortMethodsWithData('post', 'put'); - - /** - * @ngdoc property - * @name ng.$http#defaults - * @propertyOf ng.$http - * - * @description - * Runtime equivalent of the `$httpProvider.defaults` property. Allows configuration of - * default headers as well as request and response transformations. - * - * See "Setting HTTP Headers" and "Transforming Requests and Responses" sections above. - */ - $http.defaults = $config; - - - return $http; - - - function createShortMethods(names) { - forEach(arguments, function(name) { - $http[name] = function(url, config) { - return $http(extend(config || {}, { - method: name, - url: url - })); - }; - }); - } - - - function createShortMethodsWithData(name) { - forEach(arguments, function(name) { - $http[name] = function(url, data, config) { - return $http(extend(config || {}, { - method: name, - url: url, - data: data - })); - }; - }); - } - - - /** - * Makes the request - * - * !!! ACCESSES CLOSURE VARS: - * $httpBackend, $config, $log, $rootScope, defaultCache, $http.pendingRequests - */ - function sendReq(config, reqData, reqHeaders) { - var deferred = $q.defer(), - promise = deferred.promise, - cache, - cachedResp, - url = buildUrl(config.url, config.params); - - $http.pendingRequests.push(config); - promise.then(removePendingReq, removePendingReq); - - - if (config.cache && config.method == 'GET') { - cache = isObject(config.cache) ? config.cache : defaultCache; - } - - if (cache) { - cachedResp = cache.get(url); - if (cachedResp) { - if (cachedResp.then) { - // cached request has already been sent, but there is no response yet - cachedResp.then(removePendingReq, removePendingReq); - return cachedResp; - } else { - // serving from cache - if (isArray(cachedResp)) { - resolvePromise(cachedResp[1], cachedResp[0], copy(cachedResp[2])); - } else { - resolvePromise(cachedResp, 200, {}); - } - } - } else { - // put the promise for the non-transformed response into cache as a placeholder - cache.put(url, promise); - } - } - - // if we won't have the response in cache, send the request to the backend - if (!cachedResp) { - $httpBackend(config.method, url, reqData, done, reqHeaders, config.timeout, - config.withCredentials); - } - - return promise; - - - /** - * Callback registered to $httpBackend(): - * - caches the response if desired - * - resolves the raw $http promise - * - calls $apply - */ - function done(status, response, headersString) { - if (cache) { - if (isSuccess(status)) { - cache.put(url, [status, response, parseHeaders(headersString)]); - } else { - // remove promise from the cache - cache.remove(url); - } - } - - resolvePromise(response, status, headersString); - $rootScope.$apply(); - } - - - /** - * Resolves the raw $http promise. - */ - function resolvePromise(response, status, headers) { - // normalize internal statuses to 0 - status = Math.max(status, 0); - - (isSuccess(status) ? deferred.resolve : deferred.reject)({ - data: response, - status: status, - headers: headersGetter(headers), - config: config - }); - } - - - function removePendingReq() { - var idx = indexOf($http.pendingRequests, config); - if (idx !== -1) $http.pendingRequests.splice(idx, 1); - } - } - - - function buildUrl(url, params) { - if (!params) return url; - var parts = []; - forEachSorted(params, function(value, key) { - if (value == null || value == undefined) return; - if (isObject(value)) { - value = toJson(value); - } - parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)); - }); - return url + ((url.indexOf('?') == -1) ? '?' : '&') + parts.join('&'); - } - - - }]; -} -var XHR = window.XMLHttpRequest || function() { - try { return new ActiveXObject("Msxml2.XMLHTTP.6.0"); } catch (e1) {} - try { return new ActiveXObject("Msxml2.XMLHTTP.3.0"); } catch (e2) {} - try { return new ActiveXObject("Msxml2.XMLHTTP"); } catch (e3) {} - throw new Error("This browser does not support XMLHttpRequest."); -}; - - -/** - * @ngdoc object - * @name ng.$httpBackend - * @requires $browser - * @requires $window - * @requires $document - * - * @description - * HTTP backend used by the {@link ng.$http service} that delegates to - * XMLHttpRequest object or JSONP and deals with browser incompatibilities. - * - * You should never need to use this service directly, instead use the higher-level abstractions: - * {@link ng.$http $http} or {@link ngResource.$resource $resource}. - * - * During testing this implementation is swapped with {@link ngMock.$httpBackend mock - * $httpBackend} which can be trained with responses. - */ -function $HttpBackendProvider() { - this.$get = ['$browser', '$window', '$document', function($browser, $window, $document) { - return createHttpBackend($browser, XHR, $browser.defer, $window.angular.callbacks, - $document[0], $window.location.protocol.replace(':', '')); - }]; -} - -function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument, locationProtocol) { - // TODO(vojta): fix the signature - return function(method, url, post, callback, headers, timeout, withCredentials) { - $browser.$$incOutstandingRequestCount(); - url = url || $browser.url(); - - if (lowercase(method) == 'jsonp') { - var callbackId = '_' + (callbacks.counter++).toString(36); - callbacks[callbackId] = function(data) { - callbacks[callbackId].data = data; - }; - - jsonpReq(url.replace('JSON_CALLBACK', 'angular.callbacks.' + callbackId), - function() { - if (callbacks[callbackId].data) { - completeRequest(callback, 200, callbacks[callbackId].data); - } else { - completeRequest(callback, -2); - } - delete callbacks[callbackId]; - }); - } else { - var xhr = new XHR(); - xhr.open(method, url, true); - forEach(headers, function(value, key) { - if (value) xhr.setRequestHeader(key, value); - }); - - var status; - - // In IE6 and 7, this might be called synchronously when xhr.send below is called and the - // response is in the cache. the promise api will ensure that to the app code the api is - // always async - xhr.onreadystatechange = function() { - if (xhr.readyState == 4) { - var responseHeaders = xhr.getAllResponseHeaders(); - - // TODO(vojta): remove once Firefox 21 gets released. - // begin: workaround to overcome Firefox CORS http response headers bug - // https://bugzilla.mozilla.org/show_bug.cgi?id=608735 - // Firefox already patched in nightly. Should land in Firefox 21. - - // CORS "simple response headers" http://www.w3.org/TR/cors/ - var value, - simpleHeaders = ["Cache-Control", "Content-Language", "Content-Type", - "Expires", "Last-Modified", "Pragma"]; - if (!responseHeaders) { - responseHeaders = ""; - forEach(simpleHeaders, function (header) { - var value = xhr.getResponseHeader(header); - if (value) { - responseHeaders += header + ": " + value + "\n"; - } - }); - } - // end of the workaround. - - completeRequest(callback, status || xhr.status, xhr.responseText, - responseHeaders); - } - }; - - if (withCredentials) { - xhr.withCredentials = true; - } - - xhr.send(post || ''); - - if (timeout > 0) { - $browserDefer(function() { - status = -1; - xhr.abort(); - }, timeout); - } - } - - - function completeRequest(callback, status, response, headersString) { - // URL_MATCH is defined in src/service/location.js - var protocol = (url.match(URL_MATCH) || ['', locationProtocol])[1]; - - // fix status code for file protocol (it's always 0) - status = (protocol == 'file') ? (response ? 200 : 404) : status; - - // normalize IE bug (http://bugs.jquery.com/ticket/1450) - status = status == 1223 ? 204 : status; - - callback(status, response, headersString); - $browser.$$completeOutstandingRequest(noop); - } - }; - - function jsonpReq(url, done) { - // we can't use jQuery/jqLite here because jQuery does crazy shit with script elements, e.g.: - // - fetches local scripts via XHR and evals them - // - adds and immediately removes script elements from the document - var script = rawDocument.createElement('script'), - doneWrapper = function() { - rawDocument.body.removeChild(script); - if (done) done(); - }; - - script.type = 'text/javascript'; - script.src = url; - - if (msie) { - script.onreadystatechange = function() { - if (/loaded|complete/.test(script.readyState)) doneWrapper(); - }; - } else { - script.onload = script.onerror = doneWrapper; - } - - rawDocument.body.appendChild(script); - } -} - -/** - * @ngdoc object - * @name ng.$locale - * - * @description - * $locale service provides localization rules for various Angular components. As of right now the - * only public api is: - * - * * `id` – `{string}` – locale id formatted as `languageId-countryId` (e.g. `en-us`) - */ -function $LocaleProvider(){ - this.$get = function() { - return { - id: 'en-us', - - NUMBER_FORMATS: { - DECIMAL_SEP: '.', - GROUP_SEP: ',', - PATTERNS: [ - { // Decimal Pattern - minInt: 1, - minFrac: 0, - maxFrac: 3, - posPre: '', - posSuf: '', - negPre: '-', - negSuf: '', - gSize: 3, - lgSize: 3 - },{ //Currency Pattern - minInt: 1, - minFrac: 2, - maxFrac: 2, - posPre: '\u00A4', - posSuf: '', - negPre: '(\u00A4', - negSuf: ')', - gSize: 3, - lgSize: 3 - } - ], - CURRENCY_SYM: '$' - }, - - DATETIME_FORMATS: { - MONTH: 'January,February,March,April,May,June,July,August,September,October,November,December' - .split(','), - SHORTMONTH: 'Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec'.split(','), - DAY: 'Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday'.split(','), - SHORTDAY: 'Sun,Mon,Tue,Wed,Thu,Fri,Sat'.split(','), - AMPMS: ['AM','PM'], - medium: 'MMM d, y h:mm:ss a', - short: 'M/d/yy h:mm a', - fullDate: 'EEEE, MMMM d, y', - longDate: 'MMMM d, y', - mediumDate: 'MMM d, y', - shortDate: 'M/d/yy', - mediumTime: 'h:mm:ss a', - shortTime: 'h:mm a' - }, - - pluralCat: function(num) { - if (num === 1) { - return 'one'; - } - return 'other'; - } - }; - }; -} - -function $TimeoutProvider() { - this.$get = ['$rootScope', '$browser', '$q', '$exceptionHandler', - function($rootScope, $browser, $q, $exceptionHandler) { - var deferreds = {}; - - - /** - * @ngdoc function - * @name ng.$timeout - * @requires $browser - * - * @description - * Angular's wrapper for `window.setTimeout`. The `fn` function is wrapped into a try/catch - * block and delegates any exceptions to - * {@link ng.$exceptionHandler $exceptionHandler} service. - * - * The return value of registering a timeout function is a promise which will be resolved when - * the timeout is reached and the timeout function is executed. - * - * To cancel a the timeout request, call `$timeout.cancel(promise)`. - * - * In tests you can use {@link ngMock.$timeout `$timeout.flush()`} to - * synchronously flush the queue of deferred functions. - * - * @param {function()} fn A function, who's execution should be delayed. - * @param {number=} [delay=0] Delay in milliseconds. - * @param {boolean=} [invokeApply=true] If set to false skips model dirty checking, otherwise - * will invoke `fn` within the {@link ng.$rootScope.Scope#$apply $apply} block. - * @returns {Promise} Promise that will be resolved when the timeout is reached. The value this - * promise will be resolved with is the return value of the `fn` function. - */ - function timeout(fn, delay, invokeApply) { - var deferred = $q.defer(), - promise = deferred.promise, - skipApply = (isDefined(invokeApply) && !invokeApply), - timeoutId, cleanup; - - timeoutId = $browser.defer(function() { - try { - deferred.resolve(fn()); - } catch(e) { - deferred.reject(e); - $exceptionHandler(e); - } - - if (!skipApply) $rootScope.$apply(); - }, delay); - - cleanup = function() { - delete deferreds[promise.$$timeoutId]; - }; - - promise.$$timeoutId = timeoutId; - deferreds[timeoutId] = deferred; - promise.then(cleanup, cleanup); - - return promise; - } - - - /** - * @ngdoc function - * @name ng.$timeout#cancel - * @methodOf ng.$timeout - * - * @description - * Cancels a task associated with the `promise`. As a result of this the promise will be - * resolved with a rejection. - * - * @param {Promise=} promise Promise returned by the `$timeout` function. - * @returns {boolean} Returns `true` if the task hasn't executed yet and was successfully - * canceled. - */ - timeout.cancel = function(promise) { - if (promise && promise.$$timeoutId in deferreds) { - deferreds[promise.$$timeoutId].reject('canceled'); - return $browser.defer.cancel(promise.$$timeoutId); - } - return false; - }; - - return timeout; - }]; -} - -/** - * @ngdoc object - * @name ng.$filterProvider - * @description - * - * Filters are just functions which transform input to an output. However filters need to be Dependency Injected. To - * achieve this a filter definition consists of a factory function which is annotated with dependencies and is - * responsible for creating a the filter function. - * - * - * // Filter registration - * function MyModule($provide, $filterProvider) { - * // create a service to demonstrate injection (not always needed) - * $provide.value('greet', function(name){ - * return 'Hello ' + name + '!'; - * }); - * - * // register a filter factory which uses the - * // greet service to demonstrate DI. - * $filterProvider.register('greet', function(greet){ - * // return the filter function which uses the greet service - * // to generate salutation - * return function(text) { - * // filters need to be forgiving so check input validity - * return text && greet(text) || text; - * }; - * }); - * } - * - * - * The filter function is registered with the `$injector` under the filter name suffixe with `Filter`. - * - * it('should be the same instance', inject( - * function($filterProvider) { - * $filterProvider.register('reverse', function(){ - * return ...; - * }); - * }, - * function($filter, reverseFilter) { - * expect($filter('reverse')).toBe(reverseFilter); - * }); - * - * - * - * For more information about how angular filters work, and how to create your own filters, see - * {@link guide/dev_guide.templates.filters Understanding Angular Filters} in the angular Developer - * Guide. - */ -/** - * @ngdoc method - * @name ng.$filterProvider#register - * @methodOf ng.$filterProvider - * @description - * Register filter factory function. - * - * @param {String} name Name of the filter. - * @param {function} fn The filter factory function which is injectable. - */ - - -/** - * @ngdoc function - * @name ng.$filter - * @function - * @description - * Filters are used for formatting data displayed to the user. - * - * The general syntax in templates is as follows: - * - * {{ expression | [ filter_name ] }} - * - * @param {String} name Name of the filter function to retrieve - * @return {Function} the filter function - */ -$FilterProvider.$inject = ['$provide']; -function $FilterProvider($provide) { - var suffix = 'Filter'; - - function register(name, factory) { - return $provide.factory(name + suffix, factory); - } - this.register = register; - - this.$get = ['$injector', function($injector) { - return function(name) { - return $injector.get(name + suffix); - } - }]; - - //////////////////////////////////////// - - register('currency', currencyFilter); - register('date', dateFilter); - register('filter', filterFilter); - register('json', jsonFilter); - register('limitTo', limitToFilter); - register('lowercase', lowercaseFilter); - register('number', numberFilter); - register('orderBy', orderByFilter); - register('uppercase', uppercaseFilter); -} - -/** - * @ngdoc filter - * @name ng.filter:filter - * @function - * - * @description - * Selects a subset of items from `array` and returns it as a new array. - * - * Note: This function is used to augment the `Array` type in Angular expressions. See - * {@link ng.$filter} for more information about Angular arrays. - * - * @param {Array} array The source array. - * @param {string|Object|function()} expression The predicate to be used for selecting items from - * `array`. - * - * Can be one of: - * - * - `string`: Predicate that results in a substring match using the value of `expression` - * string. All strings or objects with string properties in `array` that contain this string - * will be returned. The predicate can be negated by prefixing the string with `!`. - * - * - `Object`: A pattern object can be used to filter specific properties on objects contained - * by `array`. For example `{name:"M", phone:"1"}` predicate will return an array of items - * which have property `name` containing "M" and property `phone` containing "1". A special - * property name `$` can be used (as in `{$:"text"}`) to accept a match against any - * property of the object. That's equivalent to the simple substring match with a `string` - * as described above. - * - * - `function`: A predicate function can be used to write arbitrary filters. The function is - * called for each element of `array`. The final result is an array of those elements that - * the predicate returned true for. - * - * @example - - - - - Search: - - NamePhone - - {{friend.name}} - {{friend.phone}} - - - - Any: - Name only - Phone only - - NamePhone - - {{friend.name}} - {{friend.phone}} - - - - - it('should search across all fields when filtering with a string', function() { - input('searchText').enter('m'); - expect(repeater('#searchTextResults tr', 'friend in friends').column('friend.name')). - toEqual(['Mary', 'Mike', 'Adam']); - - input('searchText').enter('76'); - expect(repeater('#searchTextResults tr', 'friend in friends').column('friend.name')). - toEqual(['John', 'Julie']); - }); - - it('should search in specific fields when filtering with a predicate object', function() { - input('search.$').enter('i'); - expect(repeater('#searchObjResults tr', 'friend in friends').column('friend.name')). - toEqual(['Mary', 'Mike', 'Julie']); - }); - - - */ -function filterFilter() { - return function(array, expression) { - if (!isArray(array)) return array; - var predicates = []; - predicates.check = function(value) { - for (var j = 0; j < predicates.length; j++) { - if(!predicates[j](value)) { - return false; - } - } - return true; - }; - var search = function(obj, text){ - if (text.charAt(0) === '!') { - return !search(obj, text.substr(1)); - } - switch (typeof obj) { - case "boolean": - case "number": - case "string": - return ('' + obj).toLowerCase().indexOf(text) > -1; - case "object": - for ( var objKey in obj) { - if (objKey.charAt(0) !== '$' && search(obj[objKey], text)) { - return true; - } - } - return false; - case "array": - for ( var i = 0; i < obj.length; i++) { - if (search(obj[i], text)) { - return true; - } - } - return false; - default: - return false; - } - }; - switch (typeof expression) { - case "boolean": - case "number": - case "string": - expression = {$:expression}; - case "object": - for (var key in expression) { - if (key == '$') { - (function() { - var text = (''+expression[key]).toLowerCase(); - if (!text) return; - predicates.push(function(value) { - return search(value, text); - }); - })(); - } else { - (function() { - var path = key; - var text = (''+expression[key]).toLowerCase(); - if (!text) return; - predicates.push(function(value) { - return search(getter(value, path), text); - }); - })(); - } - } - break; - case 'function': - predicates.push(expression); - break; - default: - return array; - } - var filtered = []; - for ( var j = 0; j < array.length; j++) { - var value = array[j]; - if (predicates.check(value)) { - filtered.push(value); - } - } - return filtered; - } -} - -/** - * @ngdoc filter - * @name ng.filter:currency - * @function - * - * @description - * Formats a number as a currency (ie $1,234.56). When no currency symbol is provided, default - * symbol for current locale is used. - * - * @param {number} amount Input to filter. - * @param {string=} symbol Currency symbol or identifier to be displayed. - * @returns {string} Formatted number. - * - * - * @example - - - - - - default currency symbol ($): {{amount | currency}} - custom currency identifier (USD$): {{amount | currency:"USD$"}} - - - - it('should init with 1234.56', function() { - expect(binding('amount | currency')).toBe('$1,234.56'); - expect(binding('amount | currency:"USD$"')).toBe('USD$1,234.56'); - }); - it('should update', function() { - input('amount').enter('-1234'); - expect(binding('amount | currency')).toBe('($1,234.00)'); - expect(binding('amount | currency:"USD$"')).toBe('(USD$1,234.00)'); - }); - - - */ -currencyFilter.$inject = ['$locale']; -function currencyFilter($locale) { - var formats = $locale.NUMBER_FORMATS; - return function(amount, currencySymbol){ - if (isUndefined(currencySymbol)) currencySymbol = formats.CURRENCY_SYM; - return formatNumber(amount, formats.PATTERNS[1], formats.GROUP_SEP, formats.DECIMAL_SEP, 2). - replace(/\u00A4/g, currencySymbol); - }; -} - -/** - * @ngdoc filter - * @name ng.filter:number - * @function - * - * @description - * Formats a number as text. - * - * If the input is not a number an empty string is returned. - * - * @param {number|string} number Number to format. - * @param {(number|string)=} [fractionSize=2] Number of decimal places to round the number to. - * @returns {string} Number rounded to decimalPlaces and places a “,” after each third digit. - * - * @example - - - - - Enter number: - Default formatting: {{val | number}} - No fractions: {{val | number:0}} - Negative number: {{-val | number:4}} - - - - it('should format numbers', function() { - expect(binding('val | number')).toBe('1,234.568'); - expect(binding('val | number:0')).toBe('1,235'); - expect(binding('-val | number:4')).toBe('-1,234.5679'); - }); - - it('should update', function() { - input('val').enter('3374.333'); - expect(binding('val | number')).toBe('3,374.333'); - expect(binding('val | number:0')).toBe('3,374'); - expect(binding('-val | number:4')).toBe('-3,374.3330'); - }); - - - */ - - -numberFilter.$inject = ['$locale']; -function numberFilter($locale) { - var formats = $locale.NUMBER_FORMATS; - return function(number, fractionSize) { - return formatNumber(number, formats.PATTERNS[0], formats.GROUP_SEP, formats.DECIMAL_SEP, - fractionSize); - }; -} - -var DECIMAL_SEP = '.'; -function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) { - if (isNaN(number) || !isFinite(number)) return ''; - - var isNegative = number < 0; - number = Math.abs(number); - var numStr = number + '', - formatedText = '', - parts = []; - - var hasExponent = false; - if (numStr.indexOf('e') !== -1) { - var match = numStr.match(/([\d\.]+)e(-?)(\d+)/); - if (match && match[2] == '-' && match[3] > fractionSize + 1) { - numStr = '0'; - } else { - formatedText = numStr; - hasExponent = true; - } - } - - if (!hasExponent) { - var fractionLen = (numStr.split(DECIMAL_SEP)[1] || '').length; - - // determine fractionSize if it is not specified - if (isUndefined(fractionSize)) { - fractionSize = Math.min(Math.max(pattern.minFrac, fractionLen), pattern.maxFrac); - } - - var pow = Math.pow(10, fractionSize); - number = Math.round(number * pow) / pow; - var fraction = ('' + number).split(DECIMAL_SEP); - var whole = fraction[0]; - fraction = fraction[1] || ''; - - var pos = 0, - lgroup = pattern.lgSize, - group = pattern.gSize; - - if (whole.length >= (lgroup + group)) { - pos = whole.length - lgroup; - for (var i = 0; i < pos; i++) { - if ((pos - i)%group === 0 && i !== 0) { - formatedText += groupSep; - } - formatedText += whole.charAt(i); - } - } - - for (i = pos; i < whole.length; i++) { - if ((whole.length - i)%lgroup === 0 && i !== 0) { - formatedText += groupSep; - } - formatedText += whole.charAt(i); - } - - // format fraction part. - while(fraction.length < fractionSize) { - fraction += '0'; - } - - if (fractionSize && fractionSize !== "0") formatedText += decimalSep + fraction.substr(0, fractionSize); - } - - parts.push(isNegative ? pattern.negPre : pattern.posPre); - parts.push(formatedText); - parts.push(isNegative ? pattern.negSuf : pattern.posSuf); - return parts.join(''); -} - -function padNumber(num, digits, trim) { - var neg = ''; - if (num < 0) { - neg = '-'; - num = -num; - } - num = '' + num; - while(num.length < digits) num = '0' + num; - if (trim) - num = num.substr(num.length - digits); - return neg + num; -} - - -function dateGetter(name, size, offset, trim) { - return function(date) { - var value = date['get' + name](); - if (offset > 0 || value > -offset) - value += offset; - if (value === 0 && offset == -12 ) value = 12; - return padNumber(value, size, trim); - }; -} - -function dateStrGetter(name, shortForm) { - return function(date, formats) { - var value = date['get' + name](); - var get = uppercase(shortForm ? ('SHORT' + name) : name); - - return formats[get][value]; - }; -} - -function timeZoneGetter(date) { - var zone = -1 * date.getTimezoneOffset(); - var paddedZone = (zone >= 0) ? "+" : ""; - - paddedZone += padNumber(zone / 60, 2) + padNumber(Math.abs(zone % 60), 2); - - return paddedZone; -} - -function ampmGetter(date, formats) { - return date.getHours() < 12 ? formats.AMPMS[0] : formats.AMPMS[1]; -} - -var DATE_FORMATS = { - yyyy: dateGetter('FullYear', 4), - yy: dateGetter('FullYear', 2, 0, true), - y: dateGetter('FullYear', 1), - MMMM: dateStrGetter('Month'), - MMM: dateStrGetter('Month', true), - MM: dateGetter('Month', 2, 1), - M: dateGetter('Month', 1, 1), - dd: dateGetter('Date', 2), - d: dateGetter('Date', 1), - HH: dateGetter('Hours', 2), - H: dateGetter('Hours', 1), - hh: dateGetter('Hours', 2, -12), - h: dateGetter('Hours', 1, -12), - mm: dateGetter('Minutes', 2), - m: dateGetter('Minutes', 1), - ss: dateGetter('Seconds', 2), - s: dateGetter('Seconds', 1), - EEEE: dateStrGetter('Day'), - EEE: dateStrGetter('Day', true), - a: ampmGetter, - Z: timeZoneGetter -}; - -var DATE_FORMATS_SPLIT = /((?:[^yMdHhmsaZE']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|d+|H+|h+|m+|s+|a|Z))(.*)/, - NUMBER_STRING = /^\d+$/; - -/** - * @ngdoc filter - * @name ng.filter:date - * @function - * - * @description - * Formats `date` to a string based on the requested `format`. - * - * `format` string can be composed of the following elements: - * - * * `'yyyy'`: 4 digit representation of year (e.g. AD 1 => 0001, AD 2010 => 2010) - * * `'yy'`: 2 digit representation of year, padded (00-99). (e.g. AD 2001 => 01, AD 2010 => 10) - * * `'y'`: 1 digit representation of year, e.g. (AD 1 => 1, AD 199 => 199) - * * `'MMMM'`: Month in year (January-December) - * * `'MMM'`: Month in year (Jan-Dec) - * * `'MM'`: Month in year, padded (01-12) - * * `'M'`: Month in year (1-12) - * * `'dd'`: Day in month, padded (01-31) - * * `'d'`: Day in month (1-31) - * * `'EEEE'`: Day in Week,(Sunday-Saturday) - * * `'EEE'`: Day in Week, (Sun-Sat) - * * `'HH'`: Hour in day, padded (00-23) - * * `'H'`: Hour in day (0-23) - * * `'hh'`: Hour in am/pm, padded (01-12) - * * `'h'`: Hour in am/pm, (1-12) - * * `'mm'`: Minute in hour, padded (00-59) - * * `'m'`: Minute in hour (0-59) - * * `'ss'`: Second in minute, padded (00-59) - * * `'s'`: Second in minute (0-59) - * * `'a'`: am/pm marker - * * `'Z'`: 4 digit (+sign) representation of the timezone offset (-1200-1200) - * - * `format` string can also be one of the following predefined - * {@link guide/i18n localizable formats}: - * - * * `'medium'`: equivalent to `'MMM d, y h:mm:ss a'` for en_US locale - * (e.g. Sep 3, 2010 12:05:08 pm) - * * `'short'`: equivalent to `'M/d/yy h:mm a'` for en_US locale (e.g. 9/3/10 12:05 pm) - * * `'fullDate'`: equivalent to `'EEEE, MMMM d,y'` for en_US locale - * (e.g. Friday, September 3, 2010) - * * `'longDate'`: equivalent to `'MMMM d, y'` for en_US locale (e.g. September 3, 2010 - * * `'mediumDate'`: equivalent to `'MMM d, y'` for en_US locale (e.g. Sep 3, 2010) - * * `'shortDate'`: equivalent to `'M/d/yy'` for en_US locale (e.g. 9/3/10) - * * `'mediumTime'`: equivalent to `'h:mm:ss a'` for en_US locale (e.g. 12:05:08 pm) - * * `'shortTime'`: equivalent to `'h:mm a'` for en_US locale (e.g. 12:05 pm) - * - * `format` string can contain literal values. These need to be quoted with single quotes (e.g. - * `"h 'in the morning'"`). In order to output single quote, use two single quotes in a sequence - * (e.g. `"h o''clock"`). - * - * @param {(Date|number|string)} date Date to format either as Date object, milliseconds (string or - * number) or various ISO 8601 datetime string formats (e.g. yyyy-MM-ddTHH:mm:ss.SSSZ and it's - * shorter versions like yyyy-MM-ddTHH:mmZ, yyyy-MM-dd or yyyyMMddTHHmmssZ). If no timezone is - * specified in the string input, the time is considered to be in the local timezone. - * @param {string=} format Formatting rules (see Description). If not specified, - * `mediumDate` is used. - * @returns {string} Formatted string or the input if input is not recognized as date/millis. - * - * @example - - - {{1288323623006 | date:'medium'}}: - {{1288323623006 | date:'medium'}} - {{1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'}}: - {{1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'}} - {{1288323623006 | date:'MM/dd/yyyy @ h:mma'}}: - {{'1288323623006' | date:'MM/dd/yyyy @ h:mma'}} - - - it('should format date', function() { - expect(binding("1288323623006 | date:'medium'")). - toMatch(/Oct 2\d, 2010 \d{1,2}:\d{2}:\d{2} (AM|PM)/); - expect(binding("1288323623006 | date:'yyyy-MM-dd HH:mm:ss Z'")). - toMatch(/2010\-10\-2\d \d{2}:\d{2}:\d{2} (\-|\+)?\d{4}/); - expect(binding("'1288323623006' | date:'MM/dd/yyyy @ h:mma'")). - toMatch(/10\/2\d\/2010 @ \d{1,2}:\d{2}(AM|PM)/); - }); - - - */ -dateFilter.$inject = ['$locale']; -function dateFilter($locale) { - - - var R_ISO8601_STR = /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/; - function jsonStringToDate(string){ - var match; - if (match = string.match(R_ISO8601_STR)) { - var date = new Date(0), - tzHour = 0, - tzMin = 0; - if (match[9]) { - tzHour = int(match[9] + match[10]); - tzMin = int(match[9] + match[11]); - } - date.setUTCFullYear(int(match[1]), int(match[2]) - 1, int(match[3])); - date.setUTCHours(int(match[4]||0) - tzHour, int(match[5]||0) - tzMin, int(match[6]||0), int(match[7]||0)); - return date; - } - return string; - } - - - return function(date, format) { - var text = '', - parts = [], - fn, match; - - format = format || 'mediumDate'; - format = $locale.DATETIME_FORMATS[format] || format; - if (isString(date)) { - if (NUMBER_STRING.test(date)) { - date = int(date); - } else { - date = jsonStringToDate(date); - } - } - - if (isNumber(date)) { - date = new Date(date); - } - - if (!isDate(date)) { - return date; - } - - while(format) { - match = DATE_FORMATS_SPLIT.exec(format); - if (match) { - parts = concat(parts, match, 1); - format = parts.pop(); - } else { - parts.push(format); - format = null; - } - } - - forEach(parts, function(value){ - fn = DATE_FORMATS[value]; - text += fn ? fn(date, $locale.DATETIME_FORMATS) - : value.replace(/(^'|'$)/g, '').replace(/''/g, "'"); - }); - - return text; - }; -} - - -/** - * @ngdoc filter - * @name ng.filter:json - * @function - * - * @description - * Allows you to convert a JavaScript object into JSON string. - * - * This filter is mostly useful for debugging. When using the double curly {{value}} notation - * the binding is automatically converted to JSON. - * - * @param {*} object Any JavaScript object (including arrays and primitive types) to filter. - * @returns {string} JSON string. - * - * - * @example: - - - {{ {'name':'value'} | json }} - - - it('should jsonify filtered objects', function() { - expect(binding("{'name':'value'}")).toMatch(/\{\n "name": ?"value"\n}/); - }); - - - * - */ -function jsonFilter() { - return function(object) { - return toJson(object, true); - }; -} - - -/** - * @ngdoc filter - * @name ng.filter:lowercase - * @function - * @description - * Converts string to lowercase. - * @see angular.lowercase - */ -var lowercaseFilter = valueFn(lowercase); - - -/** - * @ngdoc filter - * @name ng.filter:uppercase - * @function - * @description - * Converts string to uppercase. - * @see angular.uppercase - */ -var uppercaseFilter = valueFn(uppercase); - -/** - * @ngdoc function - * @name ng.filter:limitTo - * @function - * - * @description - * Creates a new array containing only a specified number of elements in an array. The elements - * are taken from either the beginning or the end of the source array, as specified by the - * value and sign (positive or negative) of `limit`. - * - * Note: This function is used to augment the `Array` type in Angular expressions. See - * {@link ng.$filter} for more information about Angular arrays. - * - * @param {Array} array Source array to be limited. - * @param {string|Number} limit The length of the returned array. If the `limit` number is - * positive, `limit` number of items from the beginning of the source array are copied. - * If the number is negative, `limit` number of items from the end of the source array are - * copied. The `limit` will be trimmed if it exceeds `array.length` - * @returns {Array} A new sub-array of length `limit` or less if input array had less than `limit` - * elements. - * - * @example - - - - - Limit {{numbers}} to: - Output: {{ numbers | limitTo:limit }} - - - - it('should limit the numer array to first three items', function() { - expect(element('.doc-example-live input[ng-model=limit]').val()).toBe('3'); - expect(binding('numbers | limitTo:limit')).toEqual('[1,2,3]'); - }); - - it('should update the output when -3 is entered', function() { - input('limit').enter(-3); - expect(binding('numbers | limitTo:limit')).toEqual('[7,8,9]'); - }); - - it('should not exceed the maximum size of input array', function() { - input('limit').enter(100); - expect(binding('numbers | limitTo:limit')).toEqual('[1,2,3,4,5,6,7,8,9]'); - }); - - - */ -function limitToFilter(){ - return function(array, limit) { - if (!(array instanceof Array)) return array; - limit = int(limit); - var out = [], - i, n; - - // check that array is iterable - if (!array || !(array instanceof Array)) - return out; - - // if abs(limit) exceeds maximum length, trim it - if (limit > array.length) - limit = array.length; - else if (limit < -array.length) - limit = -array.length; - - if (limit > 0) { - i = 0; - n = limit; - } else { - i = array.length + limit; - n = array.length; - } - - for (; i} expression A predicate to be - * used by the comparator to determine the order of elements. - * - * Can be one of: - * - * - `function`: Getter function. The result of this function will be sorted using the - * `<`, `=`, `>` operator. - * - `string`: An Angular expression which evaluates to an object to order by, such as 'name' - * to sort by a property called 'name'. Optionally prefixed with `+` or `-` to control - * ascending or descending sort order (for example, +name or -name). - * - `Array`: An array of function or string predicates. The first predicate in the array - * is used for sorting, but when two items are equivalent, the next predicate is used. - * - * @param {boolean=} reverse Reverse the order the array. - * @returns {Array} Sorted copy of the source array. - * - * @example - - - - - Sorting predicate = {{predicate}}; reverse = {{reverse}} - - [ unsorted ] - - - Name - (^) - Phone Number - Age - - - {{friend.name}} - {{friend.phone}} - {{friend.age}} - - - - - - it('should be reverse ordered by aged', function() { - expect(binding('predicate')).toBe('-age'); - expect(repeater('table.friend', 'friend in friends').column('friend.age')). - toEqual(['35', '29', '21', '19', '10']); - expect(repeater('table.friend', 'friend in friends').column('friend.name')). - toEqual(['Adam', 'Julie', 'Mike', 'Mary', 'John']); - }); - - it('should reorder the table when user selects different predicate', function() { - element('.doc-example-live a:contains("Name")').click(); - expect(repeater('table.friend', 'friend in friends').column('friend.name')). - toEqual(['Adam', 'John', 'Julie', 'Mary', 'Mike']); - expect(repeater('table.friend', 'friend in friends').column('friend.age')). - toEqual(['35', '10', '29', '19', '21']); - - element('.doc-example-live a:contains("Phone")').click(); - expect(repeater('table.friend', 'friend in friends').column('friend.phone')). - toEqual(['555-9876', '555-8765', '555-5678', '555-4321', '555-1212']); - expect(repeater('table.friend', 'friend in friends').column('friend.name')). - toEqual(['Mary', 'Julie', 'Adam', 'Mike', 'John']); - }); - - - */ -orderByFilter.$inject = ['$parse']; -function orderByFilter($parse){ - return function(array, sortPredicate, reverseOrder) { - if (!isArray(array)) return array; - if (!sortPredicate) return array; - sortPredicate = isArray(sortPredicate) ? sortPredicate: [sortPredicate]; - sortPredicate = map(sortPredicate, function(predicate){ - var descending = false, get = predicate || identity; - if (isString(predicate)) { - if ((predicate.charAt(0) == '+' || predicate.charAt(0) == '-')) { - descending = predicate.charAt(0) == '-'; - predicate = predicate.substring(1); - } - get = $parse(predicate); - } - return reverseComparator(function(a,b){ - return compare(get(a),get(b)); - }, descending); - }); - var arrayCopy = []; - for ( var i = 0; i < array.length; i++) { arrayCopy.push(array[i]); } - return arrayCopy.sort(reverseComparator(comparator, reverseOrder)); - - function comparator(o1, o2){ - for ( var i = 0; i < sortPredicate.length; i++) { - var comp = sortPredicate[i](o1, o2); - if (comp !== 0) return comp; - } - return 0; - } - function reverseComparator(comp, descending) { - return toBoolean(descending) - ? function(a,b){return comp(b,a);} - : comp; - } - function compare(v1, v2){ - var t1 = typeof v1; - var t2 = typeof v2; - if (t1 == t2) { - if (t1 == "string") v1 = v1.toLowerCase(); - if (t1 == "string") v2 = v2.toLowerCase(); - if (v1 === v2) return 0; - return v1 < v2 ? -1 : 1; - } else { - return t1 < t2 ? -1 : 1; - } - } - } -} - -function ngDirective(directive) { - if (isFunction(directive)) { - directive = { - link: directive - } - } - directive.restrict = directive.restrict || 'AC'; - return valueFn(directive); -} - -/** - * @ngdoc directive - * @name ng.directive:a - * @restrict E - * - * @description - * Modifies the default behavior of html A tag, so that the default action is prevented when href - * attribute is empty. - * - * The reasoning for this change is to allow easy creation of action links with `ngClick` directive - * without changing the location or causing page reloads, e.g.: - * `Save` - */ -var htmlAnchorDirective = valueFn({ - restrict: 'E', - compile: function(element, attr) { - - if (msie <= 8) { - - // turn link into a stylable link in IE - // but only if it doesn't have name attribute, in which case it's an anchor - if (!attr.href && !attr.name) { - attr.$set('href', ''); - } - - // add a comment node to anchors to workaround IE bug that causes element content to be reset - // to new attribute content if attribute is updated with value containing @ and element also - // contains value with @ - // see issue #1949 - element.append(document.createComment('IE fix')); - } - - return function(scope, element) { - element.bind('click', function(event){ - // if we have no href url, then don't navigate anywhere. - if (!element.attr('href')) { - event.preventDefault(); - } - }); - } - } -}); - -/** - * @ngdoc directive - * @name ng.directive:ngHref - * @restrict A - * - * @description - * Using Angular markup like {{hash}} in an href attribute makes - * the page open to a wrong URL, if the user clicks that link before - * angular has a chance to replace the {{hash}} with actual URL, the - * link will be broken and will most likely return a 404 error. - * The `ngHref` directive solves this problem. - * - * The buggy way to write it: - * - * - * - * - * The correct way to write it: - * - * - * - * - * @element A - * @param {template} ngHref any string which can contain `{{}}` markup. - * - * @example - * This example uses `link` variable inside `href` attribute: - - - - link 1 (link, don't reload) - link 2 (link, don't reload) - link 3 (link, reload!) - anchor (link, don't reload) - anchor (no link) - link (link, change location) - - - it('should execute ng-click but not reload when href without value', function() { - element('#link-1').click(); - expect(input('value').val()).toEqual('1'); - expect(element('#link-1').attr('href')).toBe(""); - }); - - it('should execute ng-click but not reload when href empty string', function() { - element('#link-2').click(); - expect(input('value').val()).toEqual('2'); - expect(element('#link-2').attr('href')).toBe(""); - }); - - it('should execute ng-click and change url when ng-href specified', function() { - expect(element('#link-3').attr('href')).toBe("/123"); - - element('#link-3').click(); - expect(browser().window().path()).toEqual('/123'); - }); - - it('should execute ng-click but not reload when href empty string and name specified', function() { - element('#link-4').click(); - expect(input('value').val()).toEqual('4'); - expect(element('#link-4').attr('href')).toBe(''); - }); - - it('should execute ng-click but not reload when no href but name specified', function() { - element('#link-5').click(); - expect(input('value').val()).toEqual('5'); - expect(element('#link-5').attr('href')).toBe(undefined); - }); - - it('should only change url when only ng-href', function() { - input('value').enter('6'); - expect(element('#link-6').attr('href')).toBe('6'); - - element('#link-6').click(); - expect(browser().location().url()).toEqual('/6'); - }); - - - */ - -/** - * @ngdoc directive - * @name ng.directive:ngSrc - * @restrict A - * - * @description - * Using Angular markup like `{{hash}}` in a `src` attribute doesn't - * work right: The browser will fetch from the URL with the literal - * text `{{hash}}` until Angular replaces the expression inside - * `{{hash}}`. The `ngSrc` directive solves this problem. - * - * The buggy way to write it: - * - * - * - * - * The correct way to write it: - * - * - * - * - * @element IMG - * @param {template} ngSrc any string which can contain `{{}}` markup. - */ - -/** - * @ngdoc directive - * @name ng.directive:ngDisabled - * @restrict A - * - * @description - * - * The following markup will make the button enabled on Chrome/Firefox but not on IE8 and older IEs: - * - * - * Disabled - * - * - * - * The HTML specs do not require browsers to preserve the special attributes such as disabled. - * (The presence of them means true and absence means false) - * This prevents the angular compiler from correctly retrieving the binding expression. - * To solve this problem, we introduce the `ngDisabled` directive. - * - * @example - - - Click me to toggle: - Button - - - it('should toggle button', function() { - expect(element('.doc-example-live :button').prop('disabled')).toBeFalsy(); - input('checked').check(); - expect(element('.doc-example-live :button').prop('disabled')).toBeTruthy(); - }); - - - * - * @element INPUT - * @param {expression} ngDisabled Angular expression that will be evaluated. - */ - - -/** - * @ngdoc directive - * @name ng.directive:ngChecked - * @restrict A - * - * @description - * The HTML specs do not require browsers to preserve the special attributes such as checked. - * (The presence of them means true and absence means false) - * This prevents the angular compiler from correctly retrieving the binding expression. - * To solve this problem, we introduce the `ngChecked` directive. - * @example - - - Check me to check both: - - - - it('should check both checkBoxes', function() { - expect(element('.doc-example-live #checkSlave').prop('checked')).toBeFalsy(); - input('master').check(); - expect(element('.doc-example-live #checkSlave').prop('checked')).toBeTruthy(); - }); - - - * - * @element INPUT - * @param {expression} ngChecked Angular expression that will be evaluated. - */ - - -/** - * @ngdoc directive - * @name ng.directive:ngMultiple - * @restrict A - * - * @description - * The HTML specs do not require browsers to preserve the special attributes such as multiple. - * (The presence of them means true and absence means false) - * This prevents the angular compiler from correctly retrieving the binding expression. - * To solve this problem, we introduce the `ngMultiple` directive. - * - * @example - - - Check me check multiple: - - Misko - Igor - Vojta - Di - - - - it('should toggle multiple', function() { - expect(element('.doc-example-live #select').prop('multiple')).toBeFalsy(); - input('checked').check(); - expect(element('.doc-example-live #select').prop('multiple')).toBeTruthy(); - }); - - - * - * @element SELECT - * @param {expression} ngMultiple Angular expression that will be evaluated. - */ - - -/** - * @ngdoc directive - * @name ng.directive:ngReadonly - * @restrict A - * - * @description - * The HTML specs do not require browsers to preserve the special attributes such as readonly. - * (The presence of them means true and absence means false) - * This prevents the angular compiler from correctly retrieving the binding expression. - * To solve this problem, we introduce the `ngReadonly` directive. - * @example - - - Check me to make text readonly: - - - - it('should toggle readonly attr', function() { - expect(element('.doc-example-live :text').prop('readonly')).toBeFalsy(); - input('checked').check(); - expect(element('.doc-example-live :text').prop('readonly')).toBeTruthy(); - }); - - - * - * @element INPUT - * @param {string} expression Angular expression that will be evaluated. - */ - - -/** - * @ngdoc directive - * @name ng.directive:ngSelected - * @restrict A - * - * @description - * The HTML specs do not require browsers to preserve the special attributes such as selected. - * (The presence of them means true and absence means false) - * This prevents the angular compiler from correctly retrieving the binding expression. - * To solve this problem, we introduced the `ngSelected` directive. - * @example - - - Check me to select: - - Hello! - Greetings! - - - - it('should select Greetings!', function() { - expect(element('.doc-example-live #greet').prop('selected')).toBeFalsy(); - input('selected').check(); - expect(element('.doc-example-live #greet').prop('selected')).toBeTruthy(); - }); - - - * - * @element OPTION - * @param {string} expression Angular expression that will be evaluated. - */ - - -var ngAttributeAliasDirectives = {}; - - -// boolean attrs are evaluated -forEach(BOOLEAN_ATTR, function(propName, attrName) { - var normalized = directiveNormalize('ng-' + attrName); - ngAttributeAliasDirectives[normalized] = function() { - return { - priority: 100, - compile: function() { - return function(scope, element, attr) { - scope.$watch(attr[normalized], function ngBooleanAttrWatchAction(value) { - attr.$set(attrName, !!value); - }); - }; - } - }; - }; -}); - - -// ng-src, ng-href are interpolated -forEach(['src', 'href'], function(attrName) { - var normalized = directiveNormalize('ng-' + attrName); - ngAttributeAliasDirectives[normalized] = function() { - return { - priority: 99, // it needs to run after the attributes are interpolated - link: function(scope, element, attr) { - attr.$observe(normalized, function(value) { - if (!value) - return; - - attr.$set(attrName, value); - - // on IE, if "ng:src" directive declaration is used and "src" attribute doesn't exist - // then calling element.setAttribute('src', 'foo') doesn't do anything, so we need - // to set the property as well to achieve the desired effect. - // we use attr[attrName] value since $set can sanitize the url. - if (msie) element.prop(attrName, attr[attrName]); - }); - } - }; - }; -}); - -var nullFormCtrl = { - $addControl: noop, - $removeControl: noop, - $setValidity: noop, - $setDirty: noop -}; - -/** - * @ngdoc object - * @name ng.directive:form.FormController - * - * @property {boolean} $pristine True if user has not interacted with the form yet. - * @property {boolean} $dirty True if user has already interacted with the form. - * @property {boolean} $valid True if all of the containing forms and controls are valid. - * @property {boolean} $invalid True if at least one containing control or form is invalid. - * - * @property {Object} $error Is an object hash, containing references to all invalid controls or - * forms, where: - * - * - keys are validation tokens (error names) — such as `required`, `url` or `email`), - * - values are arrays of controls or forms that are invalid with given error. - * - * @description - * `FormController` keeps track of all its controls and nested forms as well as state of them, - * such as being valid/invalid or dirty/pristine. - * - * Each {@link ng.directive:form form} directive creates an instance - * of `FormController`. - * - */ -//asks for $scope to fool the BC controller module -FormController.$inject = ['$element', '$attrs', '$scope']; -function FormController(element, attrs) { - var form = this, - parentForm = element.parent().controller('form') || nullFormCtrl, - invalidCount = 0, // used to easily determine if we are valid - errors = form.$error = {}; - - // init state - form.$name = attrs.name; - form.$dirty = false; - form.$pristine = true; - form.$valid = true; - form.$invalid = false; - - parentForm.$addControl(form); - - // Setup initial state of the control - element.addClass(PRISTINE_CLASS); - toggleValidCss(true); - - // convenience method for easy toggling of classes - function toggleValidCss(isValid, validationErrorKey) { - validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; - element. - removeClass((isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey). - addClass((isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); - } - - form.$addControl = function(control) { - if (control.$name && !form.hasOwnProperty(control.$name)) { - form[control.$name] = control; - } - }; - - form.$removeControl = function(control) { - if (control.$name && form[control.$name] === control) { - delete form[control.$name]; - } - forEach(errors, function(queue, validationToken) { - form.$setValidity(validationToken, true, control); - }); - }; - - form.$setValidity = function(validationToken, isValid, control) { - var queue = errors[validationToken]; - - if (isValid) { - if (queue) { - arrayRemove(queue, control); - if (!queue.length) { - invalidCount--; - if (!invalidCount) { - toggleValidCss(isValid); - form.$valid = true; - form.$invalid = false; - } - errors[validationToken] = false; - toggleValidCss(true, validationToken); - parentForm.$setValidity(validationToken, true, form); - } - } - - } else { - if (!invalidCount) { - toggleValidCss(isValid); - } - if (queue) { - if (includes(queue, control)) return; - } else { - errors[validationToken] = queue = []; - invalidCount++; - toggleValidCss(false, validationToken); - parentForm.$setValidity(validationToken, false, form); - } - queue.push(control); - - form.$valid = false; - form.$invalid = true; - } - }; - - form.$setDirty = function() { - element.removeClass(PRISTINE_CLASS).addClass(DIRTY_CLASS); - form.$dirty = true; - form.$pristine = false; - parentForm.$setDirty(); - }; - -} - - -/** - * @ngdoc directive - * @name ng.directive:ngForm - * @restrict EAC - * - * @description - * Nestable alias of {@link ng.directive:form `form`} directive. HTML - * does not allow nesting of form elements. It is useful to nest forms, for example if the validity of a - * sub-group of controls needs to be determined. - * - * @param {string=} name|ngForm Name of the form. If specified, the form controller will be published into - * related scope, under this name. - * - */ - - /** - * @ngdoc directive - * @name ng.directive:form - * @restrict E - * - * @description - * Directive that instantiates - * {@link ng.directive:form.FormController FormController}. - * - * If `name` attribute is specified, the form controller is published onto the current scope under - * this name. - * - * # Alias: {@link ng.directive:ngForm `ngForm`} - * - * In angular forms can be nested. This means that the outer form is valid when all of the child - * forms are valid as well. However browsers do not allow nesting of `` elements, for this - * reason angular provides {@link ng.directive:ngForm `ngForm`} alias - * which behaves identical to `` but allows form nesting. - * - * - * # CSS classes - * - `ng-valid` Is set if the form is valid. - * - `ng-invalid` Is set if the form is invalid. - * - `ng-pristine` Is set if the form is pristine. - * - `ng-dirty` Is set if the form is dirty. - * - * - * # Submitting a form and preventing default action - * - * Since the role of forms in client-side Angular applications is different than in classical - * roundtrip apps, it is desirable for the browser not to translate the form submission into a full - * page reload that sends the data to the server. Instead some javascript logic should be triggered - * to handle the form submission in application specific way. - * - * For this reason, Angular prevents the default action (form submission to the server) unless the - * `` element has an `action` attribute specified. - * - * You can use one of the following two ways to specify what javascript method should be called when - * a form is submitted: - * - * - {@link ng.directive:ngSubmit ngSubmit} directive on the form element - * - {@link ng.directive:ngClick ngClick} directive on the first - * button or input field of type submit (input[type=submit]) - * - * To prevent double execution of the handler, use only one of ngSubmit or ngClick directives. This - * is because of the following form submission rules coming from the html spec: - * - * - If a form has only one input field then hitting enter in this field triggers form submit - * (`ngSubmit`) - * - if a form has has 2+ input fields and no buttons or input[type=submit] then hitting enter - * doesn't trigger submit - * - if a form has one or more input fields and one or more buttons or input[type=submit] then - * hitting enter in any of the input fields will trigger the click handler on the *first* button or - * input[type=submit] (`ngClick`) *and* a submit handler on the enclosing form (`ngSubmit`) - * - * @param {string=} name Name of the form. If specified, the form controller will be published into - * related scope, under this name. - * - * @example - - - - - userType: - Required! - userType = {{userType}} - myForm.input.$valid = {{myForm.input.$valid}} - myForm.input.$error = {{myForm.input.$error}} - myForm.$valid = {{myForm.$valid}} - myForm.$error.required = {{!!myForm.$error.required}} - - - - it('should initialize to model', function() { - expect(binding('userType')).toEqual('guest'); - expect(binding('myForm.input.$valid')).toEqual('true'); - }); - - it('should be invalid if empty', function() { - input('userType').enter(''); - expect(binding('userType')).toEqual(''); - expect(binding('myForm.input.$valid')).toEqual('false'); - }); - - - */ -var formDirectiveFactory = function(isNgForm) { - return ['$timeout', function($timeout) { - var formDirective = { - name: 'form', - restrict: 'E', - controller: FormController, - compile: function() { - return { - pre: function(scope, formElement, attr, controller) { - if (!attr.action) { - // we can't use jq events because if a form is destroyed during submission the default - // action is not prevented. see #1238 - // - // IE 9 is not affected because it doesn't fire a submit event and try to do a full - // page reload if the form was destroyed by submission of the form via a click handler - // on a button in the form. Looks like an IE9 specific bug. - var preventDefaultListener = function(event) { - event.preventDefault - ? event.preventDefault() - : event.returnValue = false; // IE - }; - - addEventListenerFn(formElement[0], 'submit', preventDefaultListener); - - // unregister the preventDefault listener so that we don't not leak memory but in a - // way that will achieve the prevention of the default action. - formElement.bind('$destroy', function() { - $timeout(function() { - removeEventListenerFn(formElement[0], 'submit', preventDefaultListener); - }, 0, false); - }); - } - - var parentFormCtrl = formElement.parent().controller('form'), - alias = attr.name || attr.ngForm; - - if (alias) { - scope[alias] = controller; - } - if (parentFormCtrl) { - formElement.bind('$destroy', function() { - parentFormCtrl.$removeControl(controller); - if (alias) { - scope[alias] = undefined; - } - extend(controller, nullFormCtrl); //stop propagating child destruction handlers upwards - }); - } - } - }; - } - }; - - return isNgForm ? extend(copy(formDirective), {restrict: 'EAC'}) : formDirective; - }]; -}; - -var formDirective = formDirectiveFactory(); -var ngFormDirective = formDirectiveFactory(true); - -var URL_REGEXP = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/; -var EMAIL_REGEXP = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/; -var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/; - -var inputType = { - - /** - * @ngdoc inputType - * @name ng.directive:input.text - * - * @description - * Standard HTML text input with angular data binding. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required Adds `required` validation error key if the value is not entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * - * @example - - - - - Single word: - - Required! - - Single word only! - - text = {{text}} - myForm.input.$valid = {{myForm.input.$valid}} - myForm.input.$error = {{myForm.input.$error}} - myForm.$valid = {{myForm.$valid}} - myForm.$error.required = {{!!myForm.$error.required}} - - - - it('should initialize to model', function() { - expect(binding('text')).toEqual('guest'); - expect(binding('myForm.input.$valid')).toEqual('true'); - }); - - it('should be invalid if empty', function() { - input('text').enter(''); - expect(binding('text')).toEqual(''); - expect(binding('myForm.input.$valid')).toEqual('false'); - }); - - it('should be invalid if multi word', function() { - input('text').enter('hello world'); - expect(binding('myForm.input.$valid')).toEqual('false'); - }); - - - */ - 'text': textInputType, - - - /** - * @ngdoc inputType - * @name ng.directive:input.number - * - * @description - * Text input with number validation and transformation. Sets the `number` validation - * error if not a valid number. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} min Sets the `min` validation error key if the value entered is less then `min`. - * @param {string=} max Sets the `max` validation error key if the value entered is greater then `min`. - * @param {string=} required Sets `required` validation error key if the value is not entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * - * @example - - - - - Number: - - Required! - - Not valid number! - value = {{value}} - myForm.input.$valid = {{myForm.input.$valid}} - myForm.input.$error = {{myForm.input.$error}} - myForm.$valid = {{myForm.$valid}} - myForm.$error.required = {{!!myForm.$error.required}} - - - - it('should initialize to model', function() { - expect(binding('value')).toEqual('12'); - expect(binding('myForm.input.$valid')).toEqual('true'); - }); - - it('should be invalid if empty', function() { - input('value').enter(''); - expect(binding('value')).toEqual(''); - expect(binding('myForm.input.$valid')).toEqual('false'); - }); - - it('should be invalid if over max', function() { - input('value').enter('123'); - expect(binding('value')).toEqual(''); - expect(binding('myForm.input.$valid')).toEqual('false'); - }); - - - */ - 'number': numberInputType, - - - /** - * @ngdoc inputType - * @name ng.directive:input.url - * - * @description - * Text input with URL validation. Sets the `url` validation error key if the content is not a - * valid URL. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required Sets `required` validation error key if the value is not entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * - * @example - - - - - URL: - - Required! - - Not valid url! - text = {{text}} - myForm.input.$valid = {{myForm.input.$valid}} - myForm.input.$error = {{myForm.input.$error}} - myForm.$valid = {{myForm.$valid}} - myForm.$error.required = {{!!myForm.$error.required}} - myForm.$error.url = {{!!myForm.$error.url}} - - - - it('should initialize to model', function() { - expect(binding('text')).toEqual('http://google.com/'); - expect(binding('myForm.input.$valid')).toEqual('true'); - }); - - it('should be invalid if empty', function() { - input('text').enter(''); - expect(binding('text')).toEqual(''); - expect(binding('myForm.input.$valid')).toEqual('false'); - }); - - it('should be invalid if not url', function() { - input('text').enter('xxx'); - expect(binding('myForm.input.$valid')).toEqual('false'); - }); - - - */ - 'url': urlInputType, - - - /** - * @ngdoc inputType - * @name ng.directive:input.email - * - * @description - * Text input with email validation. Sets the `email` validation error key if not a valid email - * address. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required Sets `required` validation error key if the value is not entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * - * @example - - - - - Email: - - Required! - - Not valid email! - text = {{text}} - myForm.input.$valid = {{myForm.input.$valid}} - myForm.input.$error = {{myForm.input.$error}} - myForm.$valid = {{myForm.$valid}} - myForm.$error.required = {{!!myForm.$error.required}} - myForm.$error.email = {{!!myForm.$error.email}} - - - - it('should initialize to model', function() { - expect(binding('text')).toEqual('me@example.com'); - expect(binding('myForm.input.$valid')).toEqual('true'); - }); - - it('should be invalid if empty', function() { - input('text').enter(''); - expect(binding('text')).toEqual(''); - expect(binding('myForm.input.$valid')).toEqual('false'); - }); - - it('should be invalid if not email', function() { - input('text').enter('xxx'); - expect(binding('myForm.input.$valid')).toEqual('false'); - }); - - - */ - 'email': emailInputType, - - - /** - * @ngdoc inputType - * @name ng.directive:input.radio - * - * @description - * HTML radio button. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string} value The value to which the expression should be set when selected. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * - * @example - - - - - Red - Green - Blue - color = {{color}} - - - - it('should change state', function() { - expect(binding('color')).toEqual('blue'); - - input('color').select('red'); - expect(binding('color')).toEqual('red'); - }); - - - */ - 'radio': radioInputType, - - - /** - * @ngdoc inputType - * @name ng.directive:input.checkbox - * - * @description - * HTML checkbox. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} ngTrueValue The value to which the expression should be set when selected. - * @param {string=} ngFalseValue The value to which the expression should be set when not selected. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * - * @example - - - - - Value1: - Value2: - value1 = {{value1}} - value2 = {{value2}} - - - - it('should change state', function() { - expect(binding('value1')).toEqual('true'); - expect(binding('value2')).toEqual('YES'); - - input('value1').check(); - input('value2').check(); - expect(binding('value1')).toEqual('false'); - expect(binding('value2')).toEqual('NO'); - }); - - - */ - 'checkbox': checkboxInputType, - - 'hidden': noop, - 'button': noop, - 'submit': noop, - 'reset': noop -}; - - -function isEmpty(value) { - return isUndefined(value) || value === '' || value === null || value !== value; -} - - -function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { - - var listener = function() { - var value = trim(element.val()); - - if (ctrl.$viewValue !== value) { - scope.$apply(function() { - ctrl.$setViewValue(value); - }); - } - }; - - // if the browser does support "input" event, we are fine - except on IE9 which doesn't fire the - // input event on backspace, delete or cut - if ($sniffer.hasEvent('input')) { - element.bind('input', listener); - } else { - var timeout; - - element.bind('keydown', function(event) { - var key = event.keyCode; - - // ignore - // command modifiers arrows - if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; - - if (!timeout) { - timeout = $browser.defer(function() { - listener(); - timeout = null; - }); - } - }); - - // if user paste into input using mouse, we need "change" event to catch it - element.bind('change', listener); - } - - - ctrl.$render = function() { - element.val(isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue); - }; - - // pattern validator - var pattern = attr.ngPattern, - patternValidator; - - var validate = function(regexp, value) { - if (isEmpty(value) || regexp.test(value)) { - ctrl.$setValidity('pattern', true); - return value; - } else { - ctrl.$setValidity('pattern', false); - return undefined; - } - }; - - if (pattern) { - if (pattern.match(/^\/(.*)\/$/)) { - pattern = new RegExp(pattern.substr(1, pattern.length - 2)); - patternValidator = function(value) { - return validate(pattern, value) - }; - } else { - patternValidator = function(value) { - var patternObj = scope.$eval(pattern); - - if (!patternObj || !patternObj.test) { - throw new Error('Expected ' + pattern + ' to be a RegExp but was ' + patternObj); - } - return validate(patternObj, value); - }; - } - - ctrl.$formatters.push(patternValidator); - ctrl.$parsers.push(patternValidator); - } - - // min length validator - if (attr.ngMinlength) { - var minlength = int(attr.ngMinlength); - var minLengthValidator = function(value) { - if (!isEmpty(value) && value.length < minlength) { - ctrl.$setValidity('minlength', false); - return undefined; - } else { - ctrl.$setValidity('minlength', true); - return value; - } - }; - - ctrl.$parsers.push(minLengthValidator); - ctrl.$formatters.push(minLengthValidator); - } - - // max length validator - if (attr.ngMaxlength) { - var maxlength = int(attr.ngMaxlength); - var maxLengthValidator = function(value) { - if (!isEmpty(value) && value.length > maxlength) { - ctrl.$setValidity('maxlength', false); - return undefined; - } else { - ctrl.$setValidity('maxlength', true); - return value; - } - }; - - ctrl.$parsers.push(maxLengthValidator); - ctrl.$formatters.push(maxLengthValidator); - } -} - -function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); - - ctrl.$parsers.push(function(value) { - var empty = isEmpty(value); - if (empty || NUMBER_REGEXP.test(value)) { - ctrl.$setValidity('number', true); - return value === '' ? null : (empty ? value : parseFloat(value)); - } else { - ctrl.$setValidity('number', false); - return undefined; - } - }); - - ctrl.$formatters.push(function(value) { - return isEmpty(value) ? '' : '' + value; - }); - - if (attr.min) { - var min = parseFloat(attr.min); - var minValidator = function(value) { - if (!isEmpty(value) && value < min) { - ctrl.$setValidity('min', false); - return undefined; - } else { - ctrl.$setValidity('min', true); - return value; - } - }; - - ctrl.$parsers.push(minValidator); - ctrl.$formatters.push(minValidator); - } - - if (attr.max) { - var max = parseFloat(attr.max); - var maxValidator = function(value) { - if (!isEmpty(value) && value > max) { - ctrl.$setValidity('max', false); - return undefined; - } else { - ctrl.$setValidity('max', true); - return value; - } - }; - - ctrl.$parsers.push(maxValidator); - ctrl.$formatters.push(maxValidator); - } - - ctrl.$formatters.push(function(value) { - - if (isEmpty(value) || isNumber(value)) { - ctrl.$setValidity('number', true); - return value; - } else { - ctrl.$setValidity('number', false); - return undefined; - } - }); -} - -function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); - - var urlValidator = function(value) { - if (isEmpty(value) || URL_REGEXP.test(value)) { - ctrl.$setValidity('url', true); - return value; - } else { - ctrl.$setValidity('url', false); - return undefined; - } - }; - - ctrl.$formatters.push(urlValidator); - ctrl.$parsers.push(urlValidator); -} - -function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); - - var emailValidator = function(value) { - if (isEmpty(value) || EMAIL_REGEXP.test(value)) { - ctrl.$setValidity('email', true); - return value; - } else { - ctrl.$setValidity('email', false); - return undefined; - } - }; - - ctrl.$formatters.push(emailValidator); - ctrl.$parsers.push(emailValidator); -} - -function radioInputType(scope, element, attr, ctrl) { - // make the name unique, if not defined - if (isUndefined(attr.name)) { - element.attr('name', nextUid()); - } - - element.bind('click', function() { - if (element[0].checked) { - scope.$apply(function() { - ctrl.$setViewValue(attr.value); - }); - } - }); - - ctrl.$render = function() { - var value = attr.value; - element[0].checked = (value == ctrl.$viewValue); - }; - - attr.$observe('value', ctrl.$render); -} - -function checkboxInputType(scope, element, attr, ctrl) { - var trueValue = attr.ngTrueValue, - falseValue = attr.ngFalseValue; - - if (!isString(trueValue)) trueValue = true; - if (!isString(falseValue)) falseValue = false; - - element.bind('click', function() { - scope.$apply(function() { - ctrl.$setViewValue(element[0].checked); - }); - }); - - ctrl.$render = function() { - element[0].checked = ctrl.$viewValue; - }; - - ctrl.$formatters.push(function(value) { - return value === trueValue; - }); - - ctrl.$parsers.push(function(value) { - return value ? trueValue : falseValue; - }); -} - - -/** - * @ngdoc directive - * @name ng.directive:textarea - * @restrict E - * - * @description - * HTML textarea element control with angular data-binding. The data-binding and validation - * properties of this element are exactly the same as those of the - * {@link ng.directive:input input element}. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required Sets `required` validation error key if the value is not entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - */ - - -/** - * @ngdoc directive - * @name ng.directive:input - * @restrict E - * - * @description - * HTML input element control with angular data-binding. Input control follows HTML5 input types - * and polyfills the HTML5 validation behavior for older browsers. - * - * @param {string} ngModel Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the control is published. - * @param {string=} required Sets `required` validation error key if the value is not entered. - * @param {boolean=} ngRequired Sets `required` attribute if set to true - * @param {number=} ngMinlength Sets `minlength` validation error key if the value is shorter than - * minlength. - * @param {number=} ngMaxlength Sets `maxlength` validation error key if the value is longer than - * maxlength. - * @param {string=} ngPattern Sets `pattern` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ngChange Angular expression to be executed when input changes due to user - * interaction with the input element. - * - * @example - - - - - - User name: - - Required! - Last name: - - Too short! - - Too long! - - - user = {{user}} - myForm.userName.$valid = {{myForm.userName.$valid}} - myForm.userName.$error = {{myForm.userName.$error}} - myForm.lastName.$valid = {{myForm.lastName.$valid}} - myForm.userName.$error = {{myForm.lastName.$error}} - myForm.$valid = {{myForm.$valid}} - myForm.$error.required = {{!!myForm.$error.required}} - myForm.$error.minlength = {{!!myForm.$error.minlength}} - myForm.$error.maxlength = {{!!myForm.$error.maxlength}} - - - - it('should initialize to model', function() { - expect(binding('user')).toEqual('{"name":"guest","last":"visitor"}'); - expect(binding('myForm.userName.$valid')).toEqual('true'); - expect(binding('myForm.$valid')).toEqual('true'); - }); - - it('should be invalid if empty when required', function() { - input('user.name').enter(''); - expect(binding('user')).toEqual('{"last":"visitor"}'); - expect(binding('myForm.userName.$valid')).toEqual('false'); - expect(binding('myForm.$valid')).toEqual('false'); - }); - - it('should be valid if empty when min length is set', function() { - input('user.last').enter(''); - expect(binding('user')).toEqual('{"name":"guest","last":""}'); - expect(binding('myForm.lastName.$valid')).toEqual('true'); - expect(binding('myForm.$valid')).toEqual('true'); - }); - - it('should be invalid if less than required min length', function() { - input('user.last').enter('xx'); - expect(binding('user')).toEqual('{"name":"guest"}'); - expect(binding('myForm.lastName.$valid')).toEqual('false'); - expect(binding('myForm.lastName.$error')).toMatch(/minlength/); - expect(binding('myForm.$valid')).toEqual('false'); - }); - - it('should be invalid if longer than max length', function() { - input('user.last').enter('some ridiculously long name'); - expect(binding('user')) - .toEqual('{"name":"guest"}'); - expect(binding('myForm.lastName.$valid')).toEqual('false'); - expect(binding('myForm.lastName.$error')).toMatch(/maxlength/); - expect(binding('myForm.$valid')).toEqual('false'); - }); - - - */ -var inputDirective = ['$browser', '$sniffer', function($browser, $sniffer) { - return { - restrict: 'E', - require: '?ngModel', - link: function(scope, element, attr, ctrl) { - if (ctrl) { - (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrl, $sniffer, - $browser); - } - } - }; -}]; - -var VALID_CLASS = 'ng-valid', - INVALID_CLASS = 'ng-invalid', - PRISTINE_CLASS = 'ng-pristine', - DIRTY_CLASS = 'ng-dirty'; - -/** - * @ngdoc object - * @name ng.directive:ngModel.NgModelController - * - * @property {string} $viewValue Actual string value in the view. - * @property {*} $modelValue The value in the model, that the control is bound to. - * @property {Array.} $parsers Whenever the control reads value from the DOM, it executes - * all of these functions to sanitize / convert the value as well as validate. - * - * @property {Array.} $formatters Whenever the model value changes, it executes all of - * these functions to convert the value as well as validate. - * - * @property {Object} $error An bject hash with all errors as keys. - * - * @property {boolean} $pristine True if user has not interacted with the control yet. - * @property {boolean} $dirty True if user has already interacted with the control. - * @property {boolean} $valid True if there is no error. - * @property {boolean} $invalid True if at least one error on the control. - * - * @description - * - * `NgModelController` provides API for the `ng-model` directive. The controller contains - * services for data-binding, validation, CSS update, value formatting and parsing. It - * specifically does not contain any logic which deals with DOM rendering or listening to - * DOM events. The `NgModelController` is meant to be extended by other directives where, the - * directive provides DOM manipulation and the `NgModelController` provides the data-binding. - * - * This example shows how to use `NgModelController` with a custom control to achieve - * data-binding. Notice how different directives (`contenteditable`, `ng-model`, and `required`) - * collaborate together to achieve the desired result. - * - * - - [contenteditable] { - border: 1px solid black; - background-color: white; - min-height: 20px; - } - - .ng-invalid { - border: 1px solid red; - } - - - - angular.module('customControl', []). - directive('contenteditable', function() { - return { - restrict: 'A', // only activate on element attribute - require: '?ngModel', // get a hold of NgModelController - link: function(scope, element, attrs, ngModel) { - if(!ngModel) return; // do nothing if no ng-model - - // Specify how UI should be updated - ngModel.$render = function() { - element.html(ngModel.$viewValue || ''); - }; - - // Listen for change events to enable binding - element.bind('blur keyup change', function() { - scope.$apply(read); - }); - read(); // initialize - - // Write data to the model - function read() { - ngModel.$setViewValue(element.html()); - } - } - }; - }); - - - - Change me! - Required! - - - - - - it('should data-bind and become invalid', function() { - var contentEditable = element('[contenteditable]'); - - expect(contentEditable.text()).toEqual('Change me!'); - input('userContent').enter(''); - expect(contentEditable.text()).toEqual(''); - expect(contentEditable.prop('className')).toMatch(/ng-invalid-required/); - }); - - * - * - */ -var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', - function($scope, $exceptionHandler, $attr, $element, $parse) { - this.$viewValue = Number.NaN; - this.$modelValue = Number.NaN; - this.$parsers = []; - this.$formatters = []; - this.$viewChangeListeners = []; - this.$pristine = true; - this.$dirty = false; - this.$valid = true; - this.$invalid = false; - this.$name = $attr.name; - - var ngModelGet = $parse($attr.ngModel), - ngModelSet = ngModelGet.assign; - - if (!ngModelSet) { - throw Error(NON_ASSIGNABLE_MODEL_EXPRESSION + $attr.ngModel + - ' (' + startingTag($element) + ')'); - } - - /** - * @ngdoc function - * @name ng.directive:ngModel.NgModelController#$render - * @methodOf ng.directive:ngModel.NgModelController - * - * @description - * Called when the view needs to be updated. It is expected that the user of the ng-model - * directive will implement this method. - */ - this.$render = noop; - - var parentForm = $element.inheritedData('$formController') || nullFormCtrl, - invalidCount = 0, // used to easily determine if we are valid - $error = this.$error = {}; // keep invalid keys here - - - // Setup initial state of the control - $element.addClass(PRISTINE_CLASS); - toggleValidCss(true); - - // convenience method for easy toggling of classes - function toggleValidCss(isValid, validationErrorKey) { - validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; - $element. - removeClass((isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey). - addClass((isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); - } - - /** - * @ngdoc function - * @name ng.directive:ngModel.NgModelController#$setValidity - * @methodOf ng.directive:ngModel.NgModelController - * - * @description - * Change the validity state, and notifies the form when the control changes validity. (i.e. it - * does not notify form if given validator is already marked as invalid). - * - * This method should be called by validators - i.e. the parser or formatter functions. - * - * @param {string} validationErrorKey Name of the validator. the `validationErrorKey` will assign - * to `$error[validationErrorKey]=isValid` so that it is available for data-binding. - * The `validationErrorKey` should be in camelCase and will get converted into dash-case - * for class name. Example: `myError` will result in `ng-valid-my-error` and `ng-invalid-my-error` - * class and can be bound to as `{{someForm.someControl.$error.myError}}` . - * @param {boolean} isValid Whether the current state is valid (true) or invalid (false). - */ - this.$setValidity = function(validationErrorKey, isValid) { - if ($error[validationErrorKey] === !isValid) return; - - if (isValid) { - if ($error[validationErrorKey]) invalidCount--; - if (!invalidCount) { - toggleValidCss(true); - this.$valid = true; - this.$invalid = false; - } - } else { - toggleValidCss(false); - this.$invalid = true; - this.$valid = false; - invalidCount++; - } - - $error[validationErrorKey] = !isValid; - toggleValidCss(isValid, validationErrorKey); - - parentForm.$setValidity(validationErrorKey, isValid, this); - }; - - - /** - * @ngdoc function - * @name ng.directive:ngModel.NgModelController#$setViewValue - * @methodOf ng.directive:ngModel.NgModelController - * - * @description - * Read a value from view. - * - * This method should be called from within a DOM event handler. - * For example {@link ng.directive:input input} or - * {@link ng.directive:select select} directives call it. - * - * It internally calls all `formatters` and if resulted value is valid, updates the model and - * calls all registered change listeners. - * - * @param {string} value Value from the view. - */ - this.$setViewValue = function(value) { - this.$viewValue = value; - - // change to dirty - if (this.$pristine) { - this.$dirty = true; - this.$pristine = false; - $element.removeClass(PRISTINE_CLASS).addClass(DIRTY_CLASS); - parentForm.$setDirty(); - } - - forEach(this.$parsers, function(fn) { - value = fn(value); - }); - - if (this.$modelValue !== value) { - this.$modelValue = value; - ngModelSet($scope, value); - forEach(this.$viewChangeListeners, function(listener) { - try { - listener(); - } catch(e) { - $exceptionHandler(e); - } - }) - } - }; - - // model -> value - var ctrl = this; - - $scope.$watch(function ngModelWatch() { - var value = ngModelGet($scope); - - // if scope model value and ngModel value are out of sync - if (ctrl.$modelValue !== value) { - - var formatters = ctrl.$formatters, - idx = formatters.length; - - ctrl.$modelValue = value; - while(idx--) { - value = formatters[idx](value); - } - - if (ctrl.$viewValue !== value) { - ctrl.$viewValue = value; - ctrl.$render(); - } - } - }); -}]; - - -/** - * @ngdoc directive - * @name ng.directive:ngModel - * - * @element input - * - * @description - * Is directive that tells Angular to do two-way data binding. It works together with `input`, - * `select`, `textarea`. You can easily write your own directives to use `ngModel` as well. - * - * `ngModel` is responsible for: - * - * - binding the view into the model, which other directives such as `input`, `textarea` or `select` - * require, - * - providing validation behavior (i.e. required, number, email, url), - * - keeping state of the control (valid/invalid, dirty/pristine, validation errors), - * - setting related css class onto the element (`ng-valid`, `ng-invalid`, `ng-dirty`, `ng-pristine`), - * - register the control with parent {@link ng.directive:form form}. - * - * For basic examples, how to use `ngModel`, see: - * - * - {@link ng.directive:input input} - * - {@link ng.directive:input.text text} - * - {@link ng.directive:input.checkbox checkbox} - * - {@link ng.directive:input.radio radio} - * - {@link ng.directive:input.number number} - * - {@link ng.directive:input.email email} - * - {@link ng.directive:input.url url} - * - {@link ng.directive:select select} - * - {@link ng.directive:textarea textarea} - * - */ -var ngModelDirective = function() { - return { - require: ['ngModel', '^?form'], - controller: NgModelController, - link: function(scope, element, attr, ctrls) { - // notify others, especially parent forms - - var modelCtrl = ctrls[0], - formCtrl = ctrls[1] || nullFormCtrl; - - formCtrl.$addControl(modelCtrl); - - element.bind('$destroy', function() { - formCtrl.$removeControl(modelCtrl); - }); - } - }; -}; - - -/** - * @ngdoc directive - * @name ng.directive:ngChange - * @restrict E - * - * @description - * Evaluate given expression when user changes the input. - * The expression is not evaluated when the value change is coming from the model. - * - * Note, this directive requires `ngModel` to be present. - * - * @element input - * - * @example - * - * - * - * - * - * - * Confirmed - * debug = {{confirmed}} - * counter = {{counter}} - * - * - * - * it('should evaluate the expression if changing from view', function() { - * expect(binding('counter')).toEqual('0'); - * element('#ng-change-example1').click(); - * expect(binding('counter')).toEqual('1'); - * expect(binding('confirmed')).toEqual('true'); - * }); - * - * it('should not evaluate the expression if changing from model', function() { - * element('#ng-change-example2').click(); - * expect(binding('counter')).toEqual('0'); - * expect(binding('confirmed')).toEqual('true'); - * }); - * - * - */ -var ngChangeDirective = valueFn({ - require: 'ngModel', - link: function(scope, element, attr, ctrl) { - ctrl.$viewChangeListeners.push(function() { - scope.$eval(attr.ngChange); - }); - } -}); - - -var requiredDirective = function() { - return { - require: '?ngModel', - link: function(scope, elm, attr, ctrl) { - if (!ctrl) return; - attr.required = true; // force truthy in case we are on non input element - - var validator = function(value) { - if (attr.required && (isEmpty(value) || value === false)) { - ctrl.$setValidity('required', false); - return; - } else { - ctrl.$setValidity('required', true); - return value; - } - }; - - ctrl.$formatters.push(validator); - ctrl.$parsers.unshift(validator); - - attr.$observe('required', function() { - validator(ctrl.$viewValue); - }); - } - }; -}; - - -/** - * @ngdoc directive - * @name ng.directive:ngList - * - * @description - * Text input that converts between comma-separated string into an array of strings. - * - * @element input - * @param {string=} ngList optional delimiter that should be used to split the value. If - * specified in form `/something/` then the value will be converted into a regular expression. - * - * @example - - - - - List: - - Required! - names = {{names}} - myForm.namesInput.$valid = {{myForm.namesInput.$valid}} - myForm.namesInput.$error = {{myForm.namesInput.$error}} - myForm.$valid = {{myForm.$valid}} - myForm.$error.required = {{!!myForm.$error.required}} - - - - it('should initialize to model', function() { - expect(binding('names')).toEqual('["igor","misko","vojta"]'); - expect(binding('myForm.namesInput.$valid')).toEqual('true'); - }); - - it('should be invalid if empty', function() { - input('names').enter(''); - expect(binding('names')).toEqual('[]'); - expect(binding('myForm.namesInput.$valid')).toEqual('false'); - }); - - - */ -var ngListDirective = function() { - return { - require: 'ngModel', - link: function(scope, element, attr, ctrl) { - var match = /\/(.*)\//.exec(attr.ngList), - separator = match && new RegExp(match[1]) || attr.ngList || ','; - - var parse = function(viewValue) { - var list = []; - - if (viewValue) { - forEach(viewValue.split(separator), function(value) { - if (value) list.push(trim(value)); - }); - } - - return list; - }; - - ctrl.$parsers.push(parse); - ctrl.$formatters.push(function(value) { - if (isArray(value)) { - return value.join(', '); - } - - return undefined; - }); - } - }; -}; - - -var CONSTANT_VALUE_REGEXP = /^(true|false|\d+)$/; - -var ngValueDirective = function() { - return { - priority: 100, - compile: function(tpl, tplAttr) { - if (CONSTANT_VALUE_REGEXP.test(tplAttr.ngValue)) { - return function(scope, elm, attr) { - attr.$set('value', scope.$eval(attr.ngValue)); - }; - } else { - return function(scope, elm, attr) { - scope.$watch(attr.ngValue, function valueWatchAction(value) { - attr.$set('value', value, false); - }); - }; - } - } - }; -}; - -/** - * @ngdoc directive - * @name ng.directive:ngBind - * - * @description - * The `ngBind` attribute tells Angular to replace the text content of the specified HTML element - * with the value of a given expression, and to update the text content when the value of that - * expression changes. - * - * Typically, you don't use `ngBind` directly, but instead you use the double curly markup like - * `{{ expression }}` which is similar but less verbose. - * - * Once scenario in which the use of `ngBind` is prefered over `{{ expression }}` binding is when - * it's desirable to put bindings into template that is momentarily displayed by the browser in its - * raw state before Angular compiles it. Since `ngBind` is an element attribute, it makes the - * bindings invisible to the user while the page is loading. - * - * An alternative solution to this problem would be using the - * {@link ng.directive:ngCloak ngCloak} directive. - * - * - * @element ANY - * @param {expression} ngBind {@link guide/expression Expression} to evaluate. - * - * @example - * Enter a name in the Live Preview text box; the greeting below the text box changes instantly. - - - - - Enter name: - Hello ! - - - - it('should check ng-bind', function() { - expect(using('.doc-example-live').binding('name')).toBe('Whirled'); - using('.doc-example-live').input('name').enter('world'); - expect(using('.doc-example-live').binding('name')).toBe('world'); - }); - - - */ -var ngBindDirective = ngDirective(function(scope, element, attr) { - element.addClass('ng-binding').data('$binding', attr.ngBind); - scope.$watch(attr.ngBind, function ngBindWatchAction(value) { - element.text(value == undefined ? '' : value); - }); -}); - - -/** - * @ngdoc directive - * @name ng.directive:ngBindTemplate - * - * @description - * The `ngBindTemplate` directive specifies that the element - * text should be replaced with the template in ngBindTemplate. - * Unlike ngBind the ngBindTemplate can contain multiple `{{` `}}` - * expressions. (This is required since some HTML elements - * can not have SPAN elements such as TITLE, or OPTION to name a few.) - * - * @element ANY - * @param {string} ngBindTemplate template of form - * {{ expression }} to eval. - * - * @example - * Try it here: enter text in text box and watch the greeting change. - - - - - Salutation: - Name: - - - - - it('should check ng-bind', function() { - expect(using('.doc-example-live').binding('salutation')). - toBe('Hello'); - expect(using('.doc-example-live').binding('name')). - toBe('World'); - using('.doc-example-live').input('salutation').enter('Greetings'); - using('.doc-example-live').input('name').enter('user'); - expect(using('.doc-example-live').binding('salutation')). - toBe('Greetings'); - expect(using('.doc-example-live').binding('name')). - toBe('user'); - }); - - - */ -var ngBindTemplateDirective = ['$interpolate', function($interpolate) { - return function(scope, element, attr) { - // TODO: move this to scenario runner - var interpolateFn = $interpolate(element.attr(attr.$attr.ngBindTemplate)); - element.addClass('ng-binding').data('$binding', interpolateFn); - attr.$observe('ngBindTemplate', function(value) { - element.text(value); - }); - } -}]; - - -/** - * @ngdoc directive - * @name ng.directive:ngBindHtmlUnsafe - * - * @description - * Creates a binding that will innerHTML the result of evaluating the `expression` into the current - * element. *The innerHTML-ed content will not be sanitized!* You should use this directive only if - * {@link ngSanitize.directive:ngBindHtml ngBindHtml} directive is too - * restrictive and when you absolutely trust the source of the content you are binding to. - * - * See {@link ngSanitize.$sanitize $sanitize} docs for examples. - * - * @element ANY - * @param {expression} ngBindHtmlUnsafe {@link guide/expression Expression} to evaluate. - */ -var ngBindHtmlUnsafeDirective = [function() { - return function(scope, element, attr) { - element.addClass('ng-binding').data('$binding', attr.ngBindHtmlUnsafe); - scope.$watch(attr.ngBindHtmlUnsafe, function ngBindHtmlUnsafeWatchAction(value) { - element.html(value || ''); - }); - }; -}]; - -function classDirective(name, selector) { - name = 'ngClass' + name; - return ngDirective(function(scope, element, attr) { - var oldVal = undefined; - - scope.$watch(attr[name], ngClassWatchAction, true); - - attr.$observe('class', function(value) { - var ngClass = scope.$eval(attr[name]); - ngClassWatchAction(ngClass, ngClass); - }); - - - if (name !== 'ngClass') { - scope.$watch('$index', function($index, old$index) { - var mod = $index % 2; - if (mod !== old$index % 2) { - if (mod == selector) { - addClass(scope.$eval(attr[name])); - } else { - removeClass(scope.$eval(attr[name])); - } - } - }); - } - - - function ngClassWatchAction(newVal) { - if (selector === true || scope.$index % 2 === selector) { - if (oldVal && (newVal !== oldVal)) { - removeClass(oldVal); - } - addClass(newVal); - } - oldVal = newVal; - } - - - function removeClass(classVal) { - if (isObject(classVal) && !isArray(classVal)) { - classVal = map(classVal, function(v, k) { if (v) return k }); - } - element.removeClass(isArray(classVal) ? classVal.join(' ') : classVal); - } - - - function addClass(classVal) { - if (isObject(classVal) && !isArray(classVal)) { - classVal = map(classVal, function(v, k) { if (v) return k }); - } - if (classVal) { - element.addClass(isArray(classVal) ? classVal.join(' ') : classVal); - } - } - }); -} - -/** - * @ngdoc directive - * @name ng.directive:ngClass - * - * @description - * The `ngClass` allows you to set CSS class on HTML element dynamically by databinding an - * expression that represents all classes to be added. - * - * The directive won't add duplicate classes if a particular class was already set. - * - * When the expression changes, the previously added classes are removed and only then the - * new classes are added. - * - * @element ANY - * @param {expression} ngClass {@link guide/expression Expression} to eval. The result - * of the evaluation can be a string representing space delimited class - * names, an array, or a map of class names to boolean values. - * - * @example - - - - - - Sample Text - - - .my-class { - color: red; - } - - - it('should check ng-class', function() { - expect(element('.doc-example-live span').prop('className')).not(). - toMatch(/my-class/); - - using('.doc-example-live').element(':button:first').click(); - - expect(element('.doc-example-live span').prop('className')). - toMatch(/my-class/); - - using('.doc-example-live').element(':button:last').click(); - - expect(element('.doc-example-live span').prop('className')).not(). - toMatch(/my-class/); - }); - - - */ -var ngClassDirective = classDirective('', true); - -/** - * @ngdoc directive - * @name ng.directive:ngClassOdd - * - * @description - * The `ngClassOdd` and `ngClassEven` directives work exactly as - * {@link ng.directive:ngClass ngClass}, except it works in - * conjunction with `ngRepeat` and takes affect only on odd (even) rows. - * - * This directive can be applied only within a scope of an - * {@link ng.directive:ngRepeat ngRepeat}. - * - * @element ANY - * @param {expression} ngClassOdd {@link guide/expression Expression} to eval. The result - * of the evaluation can be a string representing space delimited class names or an array. - * - * @example - - - - - - {{name}} - - - - - - .odd { - color: red; - } - .even { - color: blue; - } - - - it('should check ng-class-odd and ng-class-even', function() { - expect(element('.doc-example-live li:first span').prop('className')). - toMatch(/odd/); - expect(element('.doc-example-live li:last span').prop('className')). - toMatch(/even/); - }); - - - */ -var ngClassOddDirective = classDirective('Odd', 0); - -/** - * @ngdoc directive - * @name ng.directive:ngClassEven - * - * @description - * The `ngClassOdd` and `ngClassEven` works exactly as - * {@link ng.directive:ngClass ngClass}, except it works in - * conjunction with `ngRepeat` and takes affect only on odd (even) rows. - * - * This directive can be applied only within a scope of an - * {@link ng.directive:ngRepeat ngRepeat}. - * - * @element ANY - * @param {expression} ngClassEven {@link guide/expression Expression} to eval. The - * result of the evaluation can be a string representing space delimited class names or an array. - * - * @example - - - - - - {{name}} - - - - - - .odd { - color: red; - } - .even { - color: blue; - } - - - it('should check ng-class-odd and ng-class-even', function() { - expect(element('.doc-example-live li:first span').prop('className')). - toMatch(/odd/); - expect(element('.doc-example-live li:last span').prop('className')). - toMatch(/even/); - }); - - - */ -var ngClassEvenDirective = classDirective('Even', 1); - -/** - * @ngdoc directive - * @name ng.directive:ngCloak - * - * @description - * The `ngCloak` directive is used to prevent the Angular html template from being briefly - * displayed by the browser in its raw (uncompiled) form while your application is loading. Use this - * directive to avoid the undesirable flicker effect caused by the html template display. - * - * The directive can be applied to the `` element, but typically a fine-grained application is - * prefered in order to benefit from progressive rendering of the browser view. - * - * `ngCloak` works in cooperation with a css rule that is embedded within `angular.js` and - * `angular.min.js` files. Following is the css rule: - * - * - * [ng\:cloak], [ng-cloak], .ng-cloak { - * display: none; - * } - * - * - * When this css rule is loaded by the browser, all html elements (including their children) that - * are tagged with the `ng-cloak` directive are hidden. When Angular comes across this directive - * during the compilation of the template it deletes the `ngCloak` element attribute, which - * makes the compiled element visible. - * - * For the best result, `angular.js` script must be loaded in the head section of the html file; - * alternatively, the css rule (above) must be included in the external stylesheet of the - * application. - * - * Legacy browsers, like IE7, do not provide attribute selector support (added in CSS 2.1) so they - * cannot match the `[ng\:cloak]` selector. To work around this limitation, you must add the css - * class `ngCloak` in addition to `ngCloak` directive as shown in the example below. - * - * @element ANY - * - * @example - - - {{ 'hello' }} - {{ 'hello IE7' }} - - - it('should remove the template directive and css class', function() { - expect(element('.doc-example-live #template1').attr('ng-cloak')). - not().toBeDefined(); - expect(element('.doc-example-live #template2').attr('ng-cloak')). - not().toBeDefined(); - }); - - - * - */ -var ngCloakDirective = ngDirective({ - compile: function(element, attr) { - attr.$set('ngCloak', undefined); - element.removeClass('ng-cloak'); - } -}); - -/** - * @ngdoc directive - * @name ng.directive:ngController - * - * @description - * The `ngController` directive assigns behavior to a scope. This is a key aspect of how angular - * supports the principles behind the Model-View-Controller design pattern. - * - * MVC components in angular: - * - * * Model — The Model is data in scope properties; scopes are attached to the DOM. - * * View — The template (HTML with data bindings) is rendered into the View. - * * Controller — The `ngController` directive specifies a Controller class; the class has - * methods that typically express the business logic behind the application. - * - * Note that an alternative way to define controllers is via the `{@link ng.$route}` - * service. - * - * @element ANY - * @scope - * @param {expression} ngController Name of a globally accessible constructor function or an - * {@link guide/expression expression} that on the current scope evaluates to a - * constructor function. - * - * @example - * Here is a simple form for editing user contact information. Adding, removing, clearing, and - * greeting are methods declared on the controller (see source tab). These methods can - * easily be called from the angular markup. Notice that the scope becomes the `this` for the - * controller's instance. This allows for easy access to the view data from the controller. Also - * notice that any changes to the data are automatically reflected in the View without the need - * for a manual update. - - - - - Name: - [ greet ] - Contact: - - - - phone - email - - - [ clear - | X ] - - [ add ] - - - - - it('should check controller', function() { - expect(element('.doc-example-live div>:input').val()).toBe('John Smith'); - expect(element('.doc-example-live li:nth-child(1) input').val()) - .toBe('408 555 1212'); - expect(element('.doc-example-live li:nth-child(2) input').val()) - .toBe('john.smith@example.org'); - - element('.doc-example-live li:first a:contains("clear")').click(); - expect(element('.doc-example-live li:first input').val()).toBe(''); - - element('.doc-example-live li:last a:contains("add")').click(); - expect(element('.doc-example-live li:nth-child(3) input').val()) - .toBe('yourname@example.org'); - }); - - - */ -var ngControllerDirective = [function() { - return { - scope: true, - controller: '@' - }; -}]; - -/** - * @ngdoc directive - * @name ng.directive:ngCsp - * @priority 1000 - * - * @description - * Enables [CSP (Content Security Policy)](https://developer.mozilla.org/en/Security/CSP) support. - * This directive should be used on the root element of the application (typically the `` - * element or other element with the {@link ng.directive:ngApp ngApp} - * directive). - * - * If enabled the performance of template expression evaluator will suffer slightly, so don't enable - * this mode unless you need it. - * - * @element html - */ - -var ngCspDirective = ['$sniffer', function($sniffer) { - return { - priority: 1000, - compile: function() { - $sniffer.csp = true; - } - }; -}]; - -/** - * @ngdoc directive - * @name ng.directive:ngClick - * - * @description - * The ngClick allows you to specify custom behavior when - * element is clicked. - * - * @element ANY - * @param {expression} ngClick {@link guide/expression Expression} to evaluate upon - * click. (Event object is available as `$event`) - * - * @example - - - - Increment - - count: {{count}} - - - it('should check ng-click', function() { - expect(binding('count')).toBe('0'); - element('.doc-example-live :button').click(); - expect(binding('count')).toBe('1'); - }); - - - */ -/* - * A directive that allows creation of custom onclick handlers that are defined as angular - * expressions and are compiled and executed within the current scope. - * - * Events that are handled via these handler are always configured not to propagate further. - */ -var ngEventDirectives = {}; -forEach( - 'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave'.split(' '), - function(name) { - var directiveName = directiveNormalize('ng-' + name); - ngEventDirectives[directiveName] = ['$parse', function($parse) { - return function(scope, element, attr) { - var fn = $parse(attr[directiveName]); - element.bind(lowercase(name), function(event) { - scope.$apply(function() { - fn(scope, {$event:event}); - }); - }); - }; - }]; - } -); - -/** - * @ngdoc directive - * @name ng.directive:ngDblclick - * - * @description - * The `ngDblclick` directive allows you to specify custom behavior on dblclick event. - * - * @element ANY - * @param {expression} ngDblclick {@link guide/expression Expression} to evaluate upon - * dblclick. (Event object is available as `$event`) - * - * @example - * See {@link ng.directive:ngClick ngClick} - */ - - -/** - * @ngdoc directive - * @name ng.directive:ngMousedown - * - * @description - * The ngMousedown directive allows you to specify custom behavior on mousedown event. - * - * @element ANY - * @param {expression} ngMousedown {@link guide/expression Expression} to evaluate upon - * mousedown. (Event object is available as `$event`) - * - * @example - * See {@link ng.directive:ngClick ngClick} - */ - - -/** - * @ngdoc directive - * @name ng.directive:ngMouseup - * - * @description - * Specify custom behavior on mouseup event. - * - * @element ANY - * @param {expression} ngMouseup {@link guide/expression Expression} to evaluate upon - * mouseup. (Event object is available as `$event`) - * - * @example - * See {@link ng.directive:ngClick ngClick} - */ - -/** - * @ngdoc directive - * @name ng.directive:ngMouseover - * - * @description - * Specify custom behavior on mouseover event. - * - * @element ANY - * @param {expression} ngMouseover {@link guide/expression Expression} to evaluate upon - * mouseover. (Event object is available as `$event`) - * - * @example - * See {@link ng.directive:ngClick ngClick} - */ - - -/** - * @ngdoc directive - * @name ng.directive:ngMouseenter - * - * @description - * Specify custom behavior on mouseenter event. - * - * @element ANY - * @param {expression} ngMouseenter {@link guide/expression Expression} to evaluate upon - * mouseenter. (Event object is available as `$event`) - * - * @example - * See {@link ng.directive:ngClick ngClick} - */ - - -/** - * @ngdoc directive - * @name ng.directive:ngMouseleave - * - * @description - * Specify custom behavior on mouseleave event. - * - * @element ANY - * @param {expression} ngMouseleave {@link guide/expression Expression} to evaluate upon - * mouseleave. (Event object is available as `$event`) - * - * @example - * See {@link ng.directive:ngClick ngClick} - */ - - -/** - * @ngdoc directive - * @name ng.directive:ngMousemove - * - * @description - * Specify custom behavior on mousemove event. - * - * @element ANY - * @param {expression} ngMousemove {@link guide/expression Expression} to evaluate upon - * mousemove. (Event object is available as `$event`) - * - * @example - * See {@link ng.directive:ngClick ngClick} - */ - - -/** - * @ngdoc directive - * @name ng.directive:ngSubmit - * - * @description - * Enables binding angular expressions to onsubmit events. - * - * Additionally it prevents the default action (which for form means sending the request to the - * server and reloading the current page). - * - * @element form - * @param {expression} ngSubmit {@link guide/expression Expression} to eval. - * - * @example - - - - - Enter text and hit enter: - - - list={{list}} - - - - it('should check ng-submit', function() { - expect(binding('list')).toBe('[]'); - element('.doc-example-live #submit').click(); - expect(binding('list')).toBe('["hello"]'); - expect(input('text').val()).toBe(''); - }); - it('should ignore empty strings', function() { - expect(binding('list')).toBe('[]'); - element('.doc-example-live #submit').click(); - element('.doc-example-live #submit').click(); - expect(binding('list')).toBe('["hello"]'); - }); - - - */ -var ngSubmitDirective = ngDirective(function(scope, element, attrs) { - element.bind('submit', function() { - scope.$apply(attrs.ngSubmit); - }); -}); - -/** - * @ngdoc directive - * @name ng.directive:ngInclude - * @restrict ECA - * - * @description - * Fetches, compiles and includes an external HTML fragment. - * - * Keep in mind that Same Origin Policy applies to included resources - * (e.g. ngInclude won't work for cross-domain requests on all browsers and for - * file:// access on some browsers). - * - * @scope - * - * @param {string} ngInclude|src angular expression evaluating to URL. If the source is a string constant, - * make sure you wrap it in quotes, e.g. `src="'myPartialTemplate.html'"`. - * @param {string=} onload Expression to evaluate when a new partial is loaded. - * - * @param {string=} autoscroll Whether `ngInclude` should call {@link ng.$anchorScroll - * $anchorScroll} to scroll the viewport after the content is loaded. - * - * - If the attribute is not set, disable scrolling. - * - If the attribute is set without value, enable scrolling. - * - Otherwise enable scrolling only if the expression evaluates to truthy value. - * - * @example - - - - - (blank) - - url of the template: {{template.url}} - - - - - - function Ctrl($scope) { - $scope.templates = - [ { name: 'template1.html', url: 'template1.html'} - , { name: 'template2.html', url: 'template2.html'} ]; - $scope.template = $scope.templates[0]; - } - - - Content of template1.html - - - Content of template2.html - - - it('should load template1.html', function() { - expect(element('.doc-example-live [ng-include]').text()). - toMatch(/Content of template1.html/); - }); - it('should load template2.html', function() { - select('template').option('1'); - expect(element('.doc-example-live [ng-include]').text()). - toMatch(/Content of template2.html/); - }); - it('should change to blank', function() { - select('template').option(''); - expect(element('.doc-example-live [ng-include]').text()).toEqual(''); - }); - - - */ - - -/** - * @ngdoc event - * @name ng.directive:ngInclude#$includeContentLoaded - * @eventOf ng.directive:ngInclude - * @eventType emit on the current ngInclude scope - * @description - * Emitted every time the ngInclude content is reloaded. - */ -var ngIncludeDirective = ['$http', '$templateCache', '$anchorScroll', '$compile', - function($http, $templateCache, $anchorScroll, $compile) { - return { - restrict: 'ECA', - terminal: true, - compile: function(element, attr) { - var srcExp = attr.ngInclude || attr.src, - onloadExp = attr.onload || '', - autoScrollExp = attr.autoscroll; - - return function(scope, element) { - var changeCounter = 0, - childScope; - - var clearContent = function() { - if (childScope) { - childScope.$destroy(); - childScope = null; - } - - element.html(''); - }; - - scope.$watch(srcExp, function ngIncludeWatchAction(src) { - var thisChangeId = ++changeCounter; - - if (src) { - $http.get(src, {cache: $templateCache}).success(function(response) { - if (thisChangeId !== changeCounter) return; - - if (childScope) childScope.$destroy(); - childScope = scope.$new(); - - element.html(response); - $compile(element.contents())(childScope); - - if (isDefined(autoScrollExp) && (!autoScrollExp || scope.$eval(autoScrollExp))) { - $anchorScroll(); - } - - childScope.$emit('$includeContentLoaded'); - scope.$eval(onloadExp); - }).error(function() { - if (thisChangeId === changeCounter) clearContent(); - }); - } else clearContent(); - }); - }; - } - }; -}]; - -/** - * @ngdoc directive - * @name ng.directive:ngInit - * - * @description - * The `ngInit` directive specifies initialization tasks to be executed - * before the template enters execution mode during bootstrap. - * - * @element ANY - * @param {expression} ngInit {@link guide/expression Expression} to eval. - * - * @example - - - - {{greeting}} {{person}}! - - - - it('should check greeting', function() { - expect(binding('greeting')).toBe('Hello'); - expect(binding('person')).toBe('World'); - }); - - - */ -var ngInitDirective = ngDirective({ - compile: function() { - return { - pre: function(scope, element, attrs) { - scope.$eval(attrs.ngInit); - } - } - } -}); - -/** - * @ngdoc directive - * @name ng.directive:ngNonBindable - * @priority 1000 - * - * @description - * Sometimes it is necessary to write code which looks like bindings but which should be left alone - * by angular. Use `ngNonBindable` to make angular ignore a chunk of HTML. - * - * @element ANY - * - * @example - * In this example there are two location where a simple binding (`{{}}`) is present, but the one - * wrapped in `ngNonBindable` is left alone. - * - * @example - - - Normal: {{1 + 2}} - Ignored: {{1 + 2}} - - - it('should check ng-non-bindable', function() { - expect(using('.doc-example-live').binding('1 + 2')).toBe('3'); - expect(using('.doc-example-live').element('div:last').text()). - toMatch(/1 \+ 2/); - }); - - - */ -var ngNonBindableDirective = ngDirective({ terminal: true, priority: 1000 }); - -/** - * @ngdoc directive - * @name ng.directive:ngPluralize - * @restrict EA - * - * @description - * # Overview - * `ngPluralize` is a directive that displays messages according to en-US localization rules. - * These rules are bundled with angular.js and the rules can be overridden - * (see {@link guide/i18n Angular i18n} dev guide). You configure ngPluralize directive - * by specifying the mappings between - * {@link http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html - * plural categories} and the strings to be displayed. - * - * # Plural categories and explicit number rules - * There are two - * {@link http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html - * plural categories} in Angular's default en-US locale: "one" and "other". - * - * While a pural category may match many numbers (for example, in en-US locale, "other" can match - * any number that is not 1), an explicit number rule can only match one number. For example, the - * explicit number rule for "3" matches the number 3. You will see the use of plural categories - * and explicit number rules throughout later parts of this documentation. - * - * # Configuring ngPluralize - * You configure ngPluralize by providing 2 attributes: `count` and `when`. - * You can also provide an optional attribute, `offset`. - * - * The value of the `count` attribute can be either a string or an {@link guide/expression - * Angular expression}; these are evaluated on the current scope for its bound value. - * - * The `when` attribute specifies the mappings between plural categories and the actual - * string to be displayed. The value of the attribute should be a JSON object so that Angular - * can interpret it correctly. - * - * The following example shows how to configure ngPluralize: - * - * - * - * - * - * - * In the example, `"0: Nobody is viewing."` is an explicit number rule. If you did not - * specify this rule, 0 would be matched to the "other" category and "0 people are viewing" - * would be shown instead of "Nobody is viewing". You can specify an explicit number rule for - * other numbers, for example 12, so that instead of showing "12 people are viewing", you can - * show "a dozen people are viewing". - * - * You can use a set of closed braces(`{}`) as a placeholder for the number that you want substituted - * into pluralized strings. In the previous example, Angular will replace `{}` with - * `{{personCount}}`. The closed braces `{}` is a placeholder - * for {{numberExpression}}. - * - * # Configuring ngPluralize with offset - * The `offset` attribute allows further customization of pluralized text, which can result in - * a better user experience. For example, instead of the message "4 people are viewing this document", - * you might display "John, Kate and 2 others are viewing this document". - * The offset attribute allows you to offset a number by any desired value. - * Let's take a look at an example: - * - * - * - * - * - * - * Notice that we are still using two plural categories(one, other), but we added - * three explicit number rules 0, 1 and 2. - * When one person, perhaps John, views the document, "John is viewing" will be shown. - * When three people view the document, no explicit number rule is found, so - * an offset of 2 is taken off 3, and Angular uses 1 to decide the plural category. - * In this case, plural category 'one' is matched and "John, Marry and one other person are viewing" - * is shown. - * - * Note that when you specify offsets, you must provide explicit number rules for - * numbers from 0 up to and including the offset. If you use an offset of 3, for example, - * you must provide explicit number rules for 0, 1, 2 and 3. You must also provide plural strings for - * plural categories "one" and "other". - * - * @param {string|expression} count The variable to be bounded to. - * @param {string} when The mapping between plural category to its correspoding strings. - * @param {number=} offset Offset to deduct from the total number. - * - * @example - - - - - Person 1: - Person 2: - Number of People: - - - Without Offset: - - - - - With Offset(2): - - - - - - it('should show correct pluralized string', function() { - expect(element('.doc-example-live ng-pluralize:first').text()). - toBe('1 person is viewing.'); - expect(element('.doc-example-live ng-pluralize:last').text()). - toBe('Igor is viewing.'); - - using('.doc-example-live').input('personCount').enter('0'); - expect(element('.doc-example-live ng-pluralize:first').text()). - toBe('Nobody is viewing.'); - expect(element('.doc-example-live ng-pluralize:last').text()). - toBe('Nobody is viewing.'); - - using('.doc-example-live').input('personCount').enter('2'); - expect(element('.doc-example-live ng-pluralize:first').text()). - toBe('2 people are viewing.'); - expect(element('.doc-example-live ng-pluralize:last').text()). - toBe('Igor and Misko are viewing.'); - - using('.doc-example-live').input('personCount').enter('3'); - expect(element('.doc-example-live ng-pluralize:first').text()). - toBe('3 people are viewing.'); - expect(element('.doc-example-live ng-pluralize:last').text()). - toBe('Igor, Misko and one other person are viewing.'); - - using('.doc-example-live').input('personCount').enter('4'); - expect(element('.doc-example-live ng-pluralize:first').text()). - toBe('4 people are viewing.'); - expect(element('.doc-example-live ng-pluralize:last').text()). - toBe('Igor, Misko and 2 other people are viewing.'); - }); - - it('should show data-binded names', function() { - using('.doc-example-live').input('personCount').enter('4'); - expect(element('.doc-example-live ng-pluralize:last').text()). - toBe('Igor, Misko and 2 other people are viewing.'); - - using('.doc-example-live').input('person1').enter('Di'); - using('.doc-example-live').input('person2').enter('Vojta'); - expect(element('.doc-example-live ng-pluralize:last').text()). - toBe('Di, Vojta and 2 other people are viewing.'); - }); - - - */ -var ngPluralizeDirective = ['$locale', '$interpolate', function($locale, $interpolate) { - var BRACE = /{}/g; - return { - restrict: 'EA', - link: function(scope, element, attr) { - var numberExp = attr.count, - whenExp = element.attr(attr.$attr.when), // this is because we have {{}} in attrs - offset = attr.offset || 0, - whens = scope.$eval(whenExp), - whensExpFns = {}, - startSymbol = $interpolate.startSymbol(), - endSymbol = $interpolate.endSymbol(); - - forEach(whens, function(expression, key) { - whensExpFns[key] = - $interpolate(expression.replace(BRACE, startSymbol + numberExp + '-' + - offset + endSymbol)); - }); - - scope.$watch(function ngPluralizeWatch() { - var value = parseFloat(scope.$eval(numberExp)); - - if (!isNaN(value)) { - //if explicit number rule such as 1, 2, 3... is defined, just use it. Otherwise, - //check it against pluralization rules in $locale service - if (!whens[value]) value = $locale.pluralCat(value - offset); - return whensExpFns[value](scope, element, true); - } else { - return ''; - } - }, function ngPluralizeWatchAction(newVal) { - element.text(newVal); - }); - } - }; -}]; - -/** - * @ngdoc directive - * @name ng.directive:ngRepeat - * - * @description - * The `ngRepeat` directive instantiates a template once per item from a collection. Each template - * instance gets its own scope, where the given loop variable is set to the current collection item, - * and `$index` is set to the item index or key. - * - * Special properties are exposed on the local scope of each template instance, including: - * - * * `$index` – `{number}` – iterator offset of the repeated element (0..length-1) - * * `$first` – `{boolean}` – true if the repeated element is first in the iterator. - * * `$middle` – `{boolean}` – true if the repeated element is between the first and last in the iterator. - * * `$last` – `{boolean}` – true if the repeated element is last in the iterator. - * - * - * @element ANY - * @scope - * @priority 1000 - * @param {repeat_expression} ngRepeat The expression indicating how to enumerate a collection. Two - * formats are currently supported: - * - * * `variable in expression` – where variable is the user defined loop variable and `expression` - * is a scope expression giving the collection to enumerate. - * - * For example: `track in cd.tracks`. - * - * * `(key, value) in expression` – where `key` and `value` can be any user defined identifiers, - * and `expression` is the scope expression giving the collection to enumerate. - * - * For example: `(name, age) in {'adam':10, 'amalie':12}`. - * - * @example - * This example initializes the scope to a list of names and - * then uses `ngRepeat` to display every person: - - - - I have {{friends.length}} friends. They are: - - - [{{$index + 1}}] {{friend.name}} who is {{friend.age}} years old. - - - - - - it('should check ng-repeat', function() { - var r = using('.doc-example-live').repeater('ul li'); - expect(r.count()).toBe(2); - expect(r.row(0)).toEqual(["1","John","25"]); - expect(r.row(1)).toEqual(["2","Mary","28"]); - }); - - - */ -var ngRepeatDirective = ngDirective({ - transclude: 'element', - priority: 1000, - terminal: true, - compile: function(element, attr, linker) { - return function(scope, iterStartElement, attr){ - var expression = attr.ngRepeat; - var match = expression.match(/^\s*(.+)\s+in\s+(.*)\s*$/), - lhs, rhs, valueIdent, keyIdent; - if (! match) { - throw Error("Expected ngRepeat in form of '_item_ in _collection_' but got '" + - expression + "'."); - } - lhs = match[1]; - rhs = match[2]; - match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); - if (!match) { - throw Error("'item' in 'item in collection' should be identifier or (key, value) but got '" + - lhs + "'."); - } - valueIdent = match[3] || match[1]; - keyIdent = match[2]; - - // Store a list of elements from previous run. This is a hash where key is the item from the - // iterator, and the value is an array of objects with following properties. - // - scope: bound scope - // - element: previous element. - // - index: position - // We need an array of these objects since the same object can be returned from the iterator. - // We expect this to be a rare case. - var lastOrder = new HashQueueMap(); - - scope.$watch(function ngRepeatWatch(scope){ - var index, length, - collection = scope.$eval(rhs), - cursor = iterStartElement, // current position of the node - // Same as lastOrder but it has the current state. It will become the - // lastOrder on the next iteration. - nextOrder = new HashQueueMap(), - arrayLength, - childScope, - key, value, // key/value of iteration - array, - last; // last object information {scope, element, index} - - - - if (!isArray(collection)) { - // if object, extract keys, sort them and use to determine order of iteration over obj props - array = []; - for(key in collection) { - if (collection.hasOwnProperty(key) && key.charAt(0) != '$') { - array.push(key); - } - } - array.sort(); - } else { - array = collection || []; - } - - arrayLength = array.length; - - // we are not using forEach for perf reasons (trying to avoid #call) - for (index = 0, length = array.length; index < length; index++) { - key = (collection === array) ? index : array[index]; - value = collection[key]; - - last = lastOrder.shift(value); - - if (last) { - // if we have already seen this object, then we need to reuse the - // associated scope/element - childScope = last.scope; - nextOrder.push(value, last); - - if (index === last.index) { - // do nothing - cursor = last.element; - } else { - // existing item which got moved - last.index = index; - // This may be a noop, if the element is next, but I don't know of a good way to - // figure this out, since it would require extra DOM access, so let's just hope that - // the browsers realizes that it is noop, and treats it as such. - cursor.after(last.element); - cursor = last.element; - } - } else { - // new item which we don't know about - childScope = scope.$new(); - } - - childScope[valueIdent] = value; - if (keyIdent) childScope[keyIdent] = key; - childScope.$index = index; - - childScope.$first = (index === 0); - childScope.$last = (index === (arrayLength - 1)); - childScope.$middle = !(childScope.$first || childScope.$last); - - if (!last) { - linker(childScope, function(clone){ - cursor.after(clone); - last = { - scope: childScope, - element: (cursor = clone), - index: index - }; - nextOrder.push(value, last); - }); - } - } - - //shrink children - for (key in lastOrder) { - if (lastOrder.hasOwnProperty(key)) { - array = lastOrder[key]; - while(array.length) { - value = array.pop(); - value.element.remove(); - value.scope.$destroy(); - } - } - } - - lastOrder = nextOrder; - }); - }; - } -}); - -/** - * @ngdoc directive - * @name ng.directive:ngShow - * - * @description - * The `ngShow` and `ngHide` directives show or hide a portion of the DOM tree (HTML) - * conditionally. - * - * @element ANY - * @param {expression} ngShow If the {@link guide/expression expression} is truthy - * then the element is shown or hidden respectively. - * - * @example - - - Click me: - Show: I show up when your checkbox is checked. - Hide: I hide when your checkbox is checked. - - - it('should check ng-show / ng-hide', function() { - expect(element('.doc-example-live span:first:hidden').count()).toEqual(1); - expect(element('.doc-example-live span:last:visible').count()).toEqual(1); - - input('checked').check(); - - expect(element('.doc-example-live span:first:visible').count()).toEqual(1); - expect(element('.doc-example-live span:last:hidden').count()).toEqual(1); - }); - - - */ -//TODO(misko): refactor to remove element from the DOM -var ngShowDirective = ngDirective(function(scope, element, attr){ - scope.$watch(attr.ngShow, function ngShowWatchAction(value){ - element.css('display', toBoolean(value) ? '' : 'none'); - }); -}); - - -/** - * @ngdoc directive - * @name ng.directive:ngHide - * - * @description - * The `ngHide` and `ngShow` directives hide or show a portion of the DOM tree (HTML) - * conditionally. - * - * @element ANY - * @param {expression} ngHide If the {@link guide/expression expression} is truthy then - * the element is shown or hidden respectively. - * - * @example - - - Click me: - Show: I show up when you checkbox is checked? - Hide: I hide when you checkbox is checked? - - - it('should check ng-show / ng-hide', function() { - expect(element('.doc-example-live span:first:hidden').count()).toEqual(1); - expect(element('.doc-example-live span:last:visible').count()).toEqual(1); - - input('checked').check(); - - expect(element('.doc-example-live span:first:visible').count()).toEqual(1); - expect(element('.doc-example-live span:last:hidden').count()).toEqual(1); - }); - - - */ -//TODO(misko): refactor to remove element from the DOM -var ngHideDirective = ngDirective(function(scope, element, attr){ - scope.$watch(attr.ngHide, function ngHideWatchAction(value){ - element.css('display', toBoolean(value) ? 'none' : ''); - }); -}); - -/** - * @ngdoc directive - * @name ng.directive:ngStyle - * - * @description - * The `ngStyle` directive allows you to set CSS style on an HTML element conditionally. - * - * @element ANY - * @param {expression} ngStyle {@link guide/expression Expression} which evals to an - * object whose keys are CSS style names and values are corresponding values for those CSS - * keys. - * - * @example - - - - - - Sample Text - myStyle={{myStyle}} - - - span { - color: black; - } - - - it('should check ng-style', function() { - expect(element('.doc-example-live span').css('color')).toBe('rgb(0, 0, 0)'); - element('.doc-example-live :button[value=set]').click(); - expect(element('.doc-example-live span').css('color')).toBe('rgb(255, 0, 0)'); - element('.doc-example-live :button[value=clear]').click(); - expect(element('.doc-example-live span').css('color')).toBe('rgb(0, 0, 0)'); - }); - - - */ -var ngStyleDirective = ngDirective(function(scope, element, attr) { - scope.$watch(attr.ngStyle, function ngStyleWatchAction(newStyles, oldStyles) { - if (oldStyles && (newStyles !== oldStyles)) { - forEach(oldStyles, function(val, style) { element.css(style, '');}); - } - if (newStyles) element.css(newStyles); - }, true); -}); - -/** - * @ngdoc directive - * @name ng.directive:ngSwitch - * @restrict EA - * - * @description - * Conditionally change the DOM structure. - * - * @usageContent - * ... - * ... - * ... - * ... - * - * @scope - * @param {*} ngSwitch|on expression to match against ng-switch-when. - * @paramDescription - * On child elments add: - * - * * `ngSwitchWhen`: the case statement to match against. If match then this - * case will be displayed. - * * `ngSwitchDefault`: the default case when no other casses match. - * - * @example - - - - - - - selection={{selection}} - - - Settings Div - Home Span - default - - - - - it('should start in settings', function() { - expect(element('.doc-example-live [ng-switch]').text()).toMatch(/Settings Div/); - }); - it('should change to home', function() { - select('selection').option('home'); - expect(element('.doc-example-live [ng-switch]').text()).toMatch(/Home Span/); - }); - it('should select deafault', function() { - select('selection').option('other'); - expect(element('.doc-example-live [ng-switch]').text()).toMatch(/default/); - }); - - - */ -var NG_SWITCH = 'ng-switch'; -var ngSwitchDirective = valueFn({ - restrict: 'EA', - require: 'ngSwitch', - // asks for $scope to fool the BC controller module - controller: ['$scope', function ngSwitchController() { - this.cases = {}; - }], - link: function(scope, element, attr, ctrl) { - var watchExpr = attr.ngSwitch || attr.on, - selectedTransclude, - selectedElement, - selectedScope; - - scope.$watch(watchExpr, function ngSwitchWatchAction(value) { - if (selectedElement) { - selectedScope.$destroy(); - selectedElement.remove(); - selectedElement = selectedScope = null; - } - if ((selectedTransclude = ctrl.cases['!' + value] || ctrl.cases['?'])) { - scope.$eval(attr.change); - selectedScope = scope.$new(); - selectedTransclude(selectedScope, function(caseElement) { - selectedElement = caseElement; - element.append(caseElement); - }); - } - }); - } -}); - -var ngSwitchWhenDirective = ngDirective({ - transclude: 'element', - priority: 500, - require: '^ngSwitch', - compile: function(element, attrs, transclude) { - return function(scope, element, attr, ctrl) { - ctrl.cases['!' + attrs.ngSwitchWhen] = transclude; - }; - } -}); - -var ngSwitchDefaultDirective = ngDirective({ - transclude: 'element', - priority: 500, - require: '^ngSwitch', - compile: function(element, attrs, transclude) { - return function(scope, element, attr, ctrl) { - ctrl.cases['?'] = transclude; - }; - } -}); - -/** - * @ngdoc directive - * @name ng.directive:ngTransclude - * - * @description - * Insert the transcluded DOM here. - * - * @element ANY - * - * @example - - - - - - - {{text}} - - - - it('should have transcluded', function() { - input('title').enter('TITLE'); - input('text').enter('TEXT'); - expect(binding('title')).toEqual('TITLE'); - expect(binding('text')).toEqual('TEXT'); - }); - - - * - */ -var ngTranscludeDirective = ngDirective({ - controller: ['$transclude', '$element', function($transclude, $element) { - $transclude(function(clone) { - $element.append(clone); - }); - }] -}); - -/** - * @ngdoc directive - * @name ng.directive:ngView - * @restrict ECA - * - * @description - * # Overview - * `ngView` is a directive that complements the {@link ng.$route $route} service by - * including the rendered template of the current route into the main layout (`index.html`) file. - * Every time the current route changes, the included view changes with it according to the - * configuration of the `$route` service. - * - * @scope - * @example - - - - Choose: - Moby | - Moby: Ch1 | - Gatsby | - Gatsby: Ch4 | - Scarlet Letter - - - - - $location.path() = {{$location.path()}} - $route.current.templateUrl = {{$route.current.templateUrl}} - $route.current.params = {{$route.current.params}} - $route.current.scope.name = {{$route.current.scope.name}} - $routeParams = {{$routeParams}} - - - - - controller: {{name}} - Book Id: {{params.bookId}} - - - - controller: {{name}} - Book Id: {{params.bookId}} - Chapter Id: {{params.chapterId}} - - - - angular.module('ngView', [], function($routeProvider, $locationProvider) { - $routeProvider.when('/Book/:bookId', { - templateUrl: 'book.html', - controller: BookCntl - }); - $routeProvider.when('/Book/:bookId/ch/:chapterId', { - templateUrl: 'chapter.html', - controller: ChapterCntl - }); - - // configure html5 to get links working on jsfiddle - $locationProvider.html5Mode(true); - }); - - function MainCntl($scope, $route, $routeParams, $location) { - $scope.$route = $route; - $scope.$location = $location; - $scope.$routeParams = $routeParams; - } - - function BookCntl($scope, $routeParams) { - $scope.name = "BookCntl"; - $scope.params = $routeParams; - } - - function ChapterCntl($scope, $routeParams) { - $scope.name = "ChapterCntl"; - $scope.params = $routeParams; - } - - - - it('should load and compile correct template', function() { - element('a:contains("Moby: Ch1")').click(); - var content = element('.doc-example-live [ng-view]').text(); - expect(content).toMatch(/controller\: ChapterCntl/); - expect(content).toMatch(/Book Id\: Moby/); - expect(content).toMatch(/Chapter Id\: 1/); - - element('a:contains("Scarlet")').click(); - content = element('.doc-example-live [ng-view]').text(); - expect(content).toMatch(/controller\: BookCntl/); - expect(content).toMatch(/Book Id\: Scarlet/); - }); - - - */ - - -/** - * @ngdoc event - * @name ng.directive:ngView#$viewContentLoaded - * @eventOf ng.directive:ngView - * @eventType emit on the current ngView scope - * @description - * Emitted every time the ngView content is reloaded. - */ -var ngViewDirective = ['$http', '$templateCache', '$route', '$anchorScroll', '$compile', - '$controller', - function($http, $templateCache, $route, $anchorScroll, $compile, - $controller) { - return { - restrict: 'ECA', - terminal: true, - link: function(scope, element, attr) { - var lastScope, - onloadExp = attr.onload || ''; - - scope.$on('$routeChangeSuccess', update); - update(); - - - function destroyLastScope() { - if (lastScope) { - lastScope.$destroy(); - lastScope = null; - } - } - - function clearContent() { - element.html(''); - destroyLastScope(); - } - - function update() { - var locals = $route.current && $route.current.locals, - template = locals && locals.$template; - - if (template) { - element.html(template); - destroyLastScope(); - - var link = $compile(element.contents()), - current = $route.current, - controller; - - lastScope = current.scope = scope.$new(); - if (current.controller) { - locals.$scope = lastScope; - controller = $controller(current.controller, locals); - element.children().data('$ngControllerController', controller); - } - - link(lastScope); - lastScope.$emit('$viewContentLoaded'); - lastScope.$eval(onloadExp); - - // $anchorScroll might listen on event... - $anchorScroll(); - } else { - clearContent(); - } - } - } - }; -}]; - -/** - * @ngdoc directive - * @name ng.directive:script - * - * @description - * Load content of a script tag, with type `text/ng-template`, into `$templateCache`, so that the - * template can be used by `ngInclude`, `ngView` or directive templates. - * - * @restrict E - * @param {'text/ng-template'} type must be set to `'text/ng-template'` - * - * @example - - - - - Load inlined template - - - - it('should load template defined inside script tag', function() { - element('#tpl-link').click(); - expect(element('#tpl-content').text()).toMatch(/Content of the template/); - }); - - - */ -var scriptDirective = ['$templateCache', function($templateCache) { - return { - restrict: 'E', - terminal: true, - compile: function(element, attr) { - if (attr.type == 'text/ng-template') { - var templateUrl = attr.id, - // IE is not consistent, in scripts we have to read .text but in other nodes we have to read .textContent - text = element[0].text; - - $templateCache.put(templateUrl, text); - } - } - }; -}]; - -/** - * @ngdoc directive - * @name ng.directive:select - * @restrict E - * - * @description - * HTML `SELECT` element with angular data-binding. - * - * # `ngOptions` - * - * Optionally `ngOptions` attribute can be used to dynamically generate a list of `` - * elements for a `` element using an array or an object obtained by evaluating the - * `ngOptions` expression. - *˝˝ - * When an item in the select menu is select, the value of array element or object property - * represented by the selected option will be bound to the model identified by the `ngModel` - * directive of the parent select element. - * - * Optionally, a single hard-coded `` element, with the value set to an empty string, can - * be nested into the `` element. This element will then represent `null` or "not selected" - * option. See example below for demonstration. - * - * Note: `ngOptions` provides iterator facility for `` element which should be used instead - * of {@link ng.directive:ngRepeat ngRepeat} when you want the - * `select` model to be bound to a non-string value. This is because an option element can currently - * be bound to string values only. - * - * @param {string} name assignable expression to data-bind to. - * @param {string=} required The control is considered valid only if value is entered. - * @param {string=} ngRequired Adds `required` attribute and `required` validation constraint to - * the element when the ngRequired expression evaluates to true. Use `ngRequired` instead of - * `required` when you want to data-bind to the `required` attribute. - * @param {comprehension_expression=} ngOptions in one of the following forms: - * - * * for array data sources: - * * `label` **`for`** `value` **`in`** `array` - * * `select` **`as`** `label` **`for`** `value` **`in`** `array` - * * `label` **`group by`** `group` **`for`** `value` **`in`** `array` - * * `select` **`as`** `label` **`group by`** `group` **`for`** `value` **`in`** `array` - * * for object data sources: - * * `label` **`for (`**`key` **`,`** `value`**`) in`** `object` - * * `select` **`as`** `label` **`for (`**`key` **`,`** `value`**`) in`** `object` - * * `label` **`group by`** `group` **`for (`**`key`**`,`** `value`**`) in`** `object` - * * `select` **`as`** `label` **`group by`** `group` - * **`for` `(`**`key`**`,`** `value`**`) in`** `object` - * - * Where: - * - * * `array` / `object`: an expression which evaluates to an array / object to iterate over. - * * `value`: local variable which will refer to each item in the `array` or each property value - * of `object` during iteration. - * * `key`: local variable which will refer to a property name in `object` during iteration. - * * `label`: The result of this expression will be the label for `` element. The - * `expression` will most likely refer to the `value` variable (e.g. `value.propertyName`). - * * `select`: The result of this expression will be bound to the model of the parent `` - * element. If not specified, `select` expression will default to `value`. - * * `group`: The result of this expression will be used to group options using the `` - * DOM element. - * - * @example - - - - - - - Name: - [X] - - - [add] - - - - Color (null not allowed): - - - Color (null allowed): - - - -- chose color -- - - - - Color grouped by shade: - - - - - Select bogus. - - Currently selected: {{ {selected_color:color} }} - - - - - - it('should check ng-options', function() { - expect(binding('{selected_color:color}')).toMatch('red'); - select('color').option('0'); - expect(binding('{selected_color:color}')).toMatch('black'); - using('.nullable').select('color').option(''); - expect(binding('{selected_color:color}')).toMatch('null'); - }); - - - */ - -var ngOptionsDirective = valueFn({ terminal: true }); -var selectDirective = ['$compile', '$parse', function($compile, $parse) { - //00001111100000000000222200000000000000000000003333000000000000044444444444444444000000000555555555555555550000000666666666666666660000000000000007777 - var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/, - nullModelCtrl = {$setViewValue: noop}; - - return { - restrict: 'E', - require: ['select', '?ngModel'], - controller: ['$element', '$scope', '$attrs', function($element, $scope, $attrs) { - var self = this, - optionsMap = {}, - ngModelCtrl = nullModelCtrl, - nullOption, - unknownOption; - - - self.databound = $attrs.ngModel; - - - self.init = function(ngModelCtrl_, nullOption_, unknownOption_) { - ngModelCtrl = ngModelCtrl_; - nullOption = nullOption_; - unknownOption = unknownOption_; - } - - - self.addOption = function(value) { - optionsMap[value] = true; - - if (ngModelCtrl.$viewValue == value) { - $element.val(value); - if (unknownOption.parent()) unknownOption.remove(); - } - }; - - - self.removeOption = function(value) { - if (this.hasOption(value)) { - delete optionsMap[value]; - if (ngModelCtrl.$viewValue == value) { - this.renderUnknownOption(value); - } - } - }; - - - self.renderUnknownOption = function(val) { - var unknownVal = '? ' + hashKey(val) + ' ?'; - unknownOption.val(unknownVal); - $element.prepend(unknownOption); - $element.val(unknownVal); - unknownOption.prop('selected', true); // needed for IE - } - - - self.hasOption = function(value) { - return optionsMap.hasOwnProperty(value); - } - - $scope.$on('$destroy', function() { - // disable unknown option so that we don't do work when the whole select is being destroyed - self.renderUnknownOption = noop; - }); - }], - - link: function(scope, element, attr, ctrls) { - // if ngModel is not defined, we don't need to do anything - if (!ctrls[1]) return; - - var selectCtrl = ctrls[0], - ngModelCtrl = ctrls[1], - multiple = attr.multiple, - optionsExp = attr.ngOptions, - nullOption = false, // if false, user will not be able to select it (used by ngOptions) - emptyOption, - // we can't just jqLite('') since jqLite is not smart enough - // to create it in and IE barfs otherwise. - optionTemplate = jqLite(document.createElement('option')), - optGroupTemplate =jqLite(document.createElement('optgroup')), - unknownOption = optionTemplate.clone(); - - // find "null" option - for(var i = 0, children = element.children(), ii = children.length; i < ii; i++) { - if (children[i].value == '') { - emptyOption = nullOption = children.eq(i); - break; - } - } - - selectCtrl.init(ngModelCtrl, nullOption, unknownOption); - - // required validator - if (multiple && (attr.required || attr.ngRequired)) { - var requiredValidator = function(value) { - ngModelCtrl.$setValidity('required', !attr.required || (value && value.length)); - return value; - }; - - ngModelCtrl.$parsers.push(requiredValidator); - ngModelCtrl.$formatters.unshift(requiredValidator); - - attr.$observe('required', function() { - requiredValidator(ngModelCtrl.$viewValue); - }); - } - - if (optionsExp) Options(scope, element, ngModelCtrl); - else if (multiple) Multiple(scope, element, ngModelCtrl); - else Single(scope, element, ngModelCtrl, selectCtrl); - - - //////////////////////////// - - - - function Single(scope, selectElement, ngModelCtrl, selectCtrl) { - ngModelCtrl.$render = function() { - var viewValue = ngModelCtrl.$viewValue; - - if (selectCtrl.hasOption(viewValue)) { - if (unknownOption.parent()) unknownOption.remove(); - selectElement.val(viewValue); - if (viewValue === '') emptyOption.prop('selected', true); // to make IE9 happy - } else { - if (isUndefined(viewValue) && emptyOption) { - selectElement.val(''); - } else { - selectCtrl.renderUnknownOption(viewValue); - } - } - }; - - selectElement.bind('change', function() { - scope.$apply(function() { - if (unknownOption.parent()) unknownOption.remove(); - ngModelCtrl.$setViewValue(selectElement.val()); - }); - }); - } - - function Multiple(scope, selectElement, ctrl) { - var lastView; - ctrl.$render = function() { - var items = new HashMap(ctrl.$viewValue); - forEach(selectElement.find('option'), function(option) { - option.selected = isDefined(items.get(option.value)); - }); - }; - - // we have to do it on each watch since ngModel watches reference, but - // we need to work of an array, so we need to see if anything was inserted/removed - scope.$watch(function selectMultipleWatch() { - if (!equals(lastView, ctrl.$viewValue)) { - lastView = copy(ctrl.$viewValue); - ctrl.$render(); - } - }); - - selectElement.bind('change', function() { - scope.$apply(function() { - var array = []; - forEach(selectElement.find('option'), function(option) { - if (option.selected) { - array.push(option.value); - } - }); - ctrl.$setViewValue(array); - }); - }); - } - - function Options(scope, selectElement, ctrl) { - var match; - - if (! (match = optionsExp.match(NG_OPTIONS_REGEXP))) { - throw Error( - "Expected ngOptions in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" + - " but got '" + optionsExp + "'."); - } - - var displayFn = $parse(match[2] || match[1]), - valueName = match[4] || match[6], - keyName = match[5], - groupByFn = $parse(match[3] || ''), - valueFn = $parse(match[2] ? match[1] : valueName), - valuesFn = $parse(match[7]), - // This is an array of array of existing option groups in DOM. We try to reuse these if possible - // optionGroupsCache[0] is the options with no option group - // optionGroupsCache[?][0] is the parent: either the SELECT or OPTGROUP element - optionGroupsCache = [[{element: selectElement, label:''}]]; - - if (nullOption) { - // compile the element since there might be bindings in it - $compile(nullOption)(scope); - - // remove the class, which is added automatically because we recompile the element and it - // becomes the compilation root - nullOption.removeClass('ng-scope'); - - // we need to remove it before calling selectElement.html('') because otherwise IE will - // remove the label from the element. wtf? - nullOption.remove(); - } - - // clear contents, we'll add what's needed based on the model - selectElement.html(''); - - selectElement.bind('change', function() { - scope.$apply(function() { - var optionGroup, - collection = valuesFn(scope) || [], - locals = {}, - key, value, optionElement, index, groupIndex, length, groupLength; - - if (multiple) { - value = []; - for (groupIndex = 0, groupLength = optionGroupsCache.length; - groupIndex < groupLength; - groupIndex++) { - // list of options for that group. (first item has the parent) - optionGroup = optionGroupsCache[groupIndex]; - - for(index = 1, length = optionGroup.length; index < length; index++) { - if ((optionElement = optionGroup[index].element)[0].selected) { - key = optionElement.val(); - if (keyName) locals[keyName] = key; - locals[valueName] = collection[key]; - value.push(valueFn(scope, locals)); - } - } - } - } else { - key = selectElement.val(); - if (key == '?') { - value = undefined; - } else if (key == ''){ - value = null; - } else { - locals[valueName] = collection[key]; - if (keyName) locals[keyName] = key; - value = valueFn(scope, locals); - } - } - ctrl.$setViewValue(value); - }); - }); - - ctrl.$render = render; - - // TODO(vojta): can't we optimize this ? - scope.$watch(render); - - function render() { - var optionGroups = {'':[]}, // Temporary location for the option groups before we render them - optionGroupNames = [''], - optionGroupName, - optionGroup, - option, - existingParent, existingOptions, existingOption, - modelValue = ctrl.$modelValue, - values = valuesFn(scope) || [], - keys = keyName ? sortedKeys(values) : values, - groupLength, length, - groupIndex, index, - locals = {}, - selected, - selectedSet = false, // nothing is selected yet - lastElement, - element, - label; - - if (multiple) { - selectedSet = new HashMap(modelValue); - } else if (modelValue === null || nullOption) { - // if we are not multiselect, and we are null then we have to add the nullOption - optionGroups[''].push({selected:modelValue === null, id:'', label:''}); - selectedSet = true; - } - - // We now build up the list of options we need (we merge later) - for (index = 0; length = keys.length, index < length; index++) { - locals[valueName] = values[keyName ? locals[keyName]=keys[index]:index]; - optionGroupName = groupByFn(scope, locals) || ''; - if (!(optionGroup = optionGroups[optionGroupName])) { - optionGroup = optionGroups[optionGroupName] = []; - optionGroupNames.push(optionGroupName); - } - if (multiple) { - selected = selectedSet.remove(valueFn(scope, locals)) != undefined; - } else { - selected = modelValue === valueFn(scope, locals); - selectedSet = selectedSet || selected; // see if at least one item is selected - } - label = displayFn(scope, locals); // what will be seen by the user - label = label === undefined ? '' : label; // doing displayFn(scope, locals) || '' overwrites zero values - optionGroup.push({ - id: keyName ? keys[index] : index, // either the index into array or key from object - label: label, - selected: selected // determine if we should be selected - }); - } - if (!multiple && !selectedSet) { - // nothing was selected, we have to insert the undefined item - optionGroups[''].unshift({id:'?', label:'', selected:true}); - } - - // Now we need to update the list of DOM nodes to match the optionGroups we computed above - for (groupIndex = 0, groupLength = optionGroupNames.length; - groupIndex < groupLength; - groupIndex++) { - // current option group name or '' if no group - optionGroupName = optionGroupNames[groupIndex]; - - // list of options for that group. (first item has the parent) - optionGroup = optionGroups[optionGroupName]; - - if (optionGroupsCache.length <= groupIndex) { - // we need to grow the optionGroups - existingParent = { - element: optGroupTemplate.clone().attr('label', optionGroupName), - label: optionGroup.label - }; - existingOptions = [existingParent]; - optionGroupsCache.push(existingOptions); - selectElement.append(existingParent.element); - } else { - existingOptions = optionGroupsCache[groupIndex]; - existingParent = existingOptions[0]; // either SELECT (no group) or OPTGROUP element - - // update the OPTGROUP label if not the same. - if (existingParent.label != optionGroupName) { - existingParent.element.attr('label', existingParent.label = optionGroupName); - } - } - - lastElement = null; // start at the beginning - for(index = 0, length = optionGroup.length; index < length; index++) { - option = optionGroup[index]; - if ((existingOption = existingOptions[index+1])) { - // reuse elements - lastElement = existingOption.element; - if (existingOption.label !== option.label) { - lastElement.text(existingOption.label = option.label); - } - if (existingOption.id !== option.id) { - lastElement.val(existingOption.id = option.id); - } - if (existingOption.element.selected !== option.selected) { - lastElement.prop('selected', (existingOption.selected = option.selected)); - } - } else { - // grow elements - - // if it's a null option - if (option.id === '' && nullOption) { - // put back the pre-compiled element - element = nullOption; - } else { - // jQuery(v1.4.2) Bug: We should be able to chain the method calls, but - // in this version of jQuery on some browser the .text() returns a string - // rather then the element. - (element = optionTemplate.clone()) - .val(option.id) - .attr('selected', option.selected) - .text(option.label); - } - - existingOptions.push(existingOption = { - element: element, - label: option.label, - id: option.id, - selected: option.selected - }); - if (lastElement) { - lastElement.after(element); - } else { - existingParent.element.append(element); - } - lastElement = element; - } - } - // remove any excessive OPTIONs in a group - index++; // increment since the existingOptions[0] is parent element not OPTION - while(existingOptions.length > index) { - existingOptions.pop().element.remove(); - } - } - // remove any excessive OPTGROUPs from select - while(optionGroupsCache.length > groupIndex) { - optionGroupsCache.pop()[0].element.remove(); - } - } - } - } - } -}]; - -var optionDirective = ['$interpolate', function($interpolate) { - var nullSelectCtrl = { - addOption: noop, - removeOption: noop - }; - - return { - restrict: 'E', - priority: 100, - compile: function(element, attr) { - if (isUndefined(attr.value)) { - var interpolateFn = $interpolate(element.text(), true); - if (!interpolateFn) { - attr.$set('value', element.text()); - } - } - - return function (scope, element, attr) { - var selectCtrlName = '$selectController', - parent = element.parent(), - selectCtrl = parent.data(selectCtrlName) || - parent.parent().data(selectCtrlName); // in case we are in optgroup - - if (selectCtrl && selectCtrl.databound) { - // For some reason Opera defaults to true and if not overridden this messes up the repeater. - // We don't want the view to drive the initialization of the model anyway. - element.prop('selected', false); - } else { - selectCtrl = nullSelectCtrl; - } - - if (interpolateFn) { - scope.$watch(interpolateFn, function interpolateWatchAction(newVal, oldVal) { - attr.$set('value', newVal); - if (newVal !== oldVal) selectCtrl.removeOption(oldVal); - selectCtrl.addOption(newVal); - }); - } else { - selectCtrl.addOption(attr.value); - } - - element.bind('$destroy', function() { - selectCtrl.removeOption(attr.value); - }); - }; - } - } -}]; - -var styleDirective = valueFn({ - restrict: 'E', - terminal: true -}); - //try to bind to jquery now so that one can write angular.element().read() - //but we will rebind on bootstrap again. - bindJQuery(); - - publishExternalAPI(angular); - - jqLite(document).ready(function() { - angularInit(document, bootstrap); - }); - -})(window, document); -angular.element(document).find('head').append(''); \ No newline at end of file diff --git a/misc/test-lib/helpers.js b/misc/test-lib/helpers.js index c453b12467..452c7fd41b 100644 --- a/misc/test-lib/helpers.js +++ b/misc/test-lib/helpers.js @@ -1,13 +1,57 @@ // jasmine matcher for expecting an element to have a css class // https://github.com/angular/angular.js/blob/master/test/matchers.js beforeEach(function() { - this.addMatchers({ - toHaveClass: function(cls) { - this.message = function() { - return "Expected '" + angular.mock.dump(this.actual) + "' to have class '" + cls + "'."; - }; + jasmine.addMatchers({ + toHaveClass: function(util, customEqualityTesters) { + return { + compare: function(actual, expected) { + var result = { + pass: actual.hasClass(expected) + }; - return this.actual.hasClass(cls); + if (result.pass) { + result.message = 'Expected "' + actual + '" not to have the "' + expected + '" class.'; + } else { + result.message = 'Expected "' + actual + '" to have the "' + expected + '" class.'; + } + + return result; + } + } + }, + toBeHidden: function(util, customEqualityTesters) { + return { + compare: function(actual) { + var result = { + pass: actual.hasClass('ng-hide') || actual.css('display') === 'none' + }; + + if (result.pass) { + result.message = 'Expected "' + actual + '" not to be hidden'; + } else { + result.message = 'Expected "' + actual + '" to be hidden'; + } + + return result; + } + } + }, + toHaveFocus: function(util, customEqualityTesters) { + return { + compare: function(actual) { + var result = { + pass: document.activeElement === actual[0] + }; + + if (result.pass) { + result.message = 'Expected "' + actual + '" not to have focus'; + } else { + result.message = 'Expected "' + actual + '" to have focus'; + } + + return result; + } + } } }); }); diff --git a/package.json b/package.json index 9e88e7df57..ca5cfe407d 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,61 @@ { "author": "https://github.com/angular-ui/bootstrap/graphs/contributors", "name": "angular-ui-bootstrap", - "version": "0.6.0-SNAPSHOT", + "version": "2.5.4", + "description": "Native AngularJS (Angular) directives for Bootstrap", + "homepage": "http://angular-ui.github.io/bootstrap/", + "keywords": [ + "angularjs", + "angular", + "bootstrap", + "ui" + ], "dependencies": {}, + "directories": { + "lib": "src/" + }, + "files": [ + "index.js", + "dist/", + "src/", + "template/" + ], + "main": "index.js", + "scripts": { + "demo": "grunt after-test && static dist -a 0.0.0.0 -H '{\"Cache-Control\": \"no-cache, must-revalidate\"}'", + "test": "grunt" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular-ui/bootstrap.git" + }, "devDependencies": { - "grunt": "~0.4.1", - "grunt-ngdocs": "~0.1.1", - "grunt-conventional-changelog": "~0.1.2", - "grunt-contrib-concat": "~0.1.3", - "grunt-contrib-copy": "~0.4.1", - "grunt-contrib-uglify": "~0.2.0", - "grunt-contrib-watch": "~0.3.1", - "grunt-contrib-jshint": "~0.4.0", - "grunt-html2js": "~0.1.3", - "grunt-karma": "~0.4.4", - "node-markdown": "0.1.1", - "semver": "~1.1.4", - "shelljs": "~0.1.4" - } + "angular": "1.6.1", + "angular-mocks": "1.6.1", + "angular-sanitize": "1.6.1", + "grunt": "^0.4.5", + "grunt-cli": "^1.2.0", + "grunt-contrib-concat": "^1.0.0", + "grunt-contrib-copy": "^1.0.0", + "grunt-contrib-uglify": "^1.0.1", + "grunt-contrib-watch": "^1.0.0", + "grunt-conventional-changelog": "^6.1.0", + "grunt-ddescribe-iit": "0.0.6", + "grunt-eslint": "^17.3.1", + "grunt-html2js": "^0.3.0", + "grunt-karma": "^0.12.0", + "jasmine-core": "^2.2.0", + "karma": "0.13.22", + "karma-chrome-launcher": "^0.2.0", + "karma-coverage": "^0.5.0", + "karma-firefox-launcher": "^0.1.4", + "karma-jasmine": "^0.3.5", + "load-grunt-tasks": "^3.3.0", + "lodash": "^4.1.0", + "marked": "^0.3.5", + "node-static": "^0.7.8", + "semver": "^5.0.1", + "shelljs": "^0.6.0" + }, + "license": "MIT" } diff --git a/src/accordion/accordion.js b/src/accordion/accordion.js index 9541aea01b..9b07a96131 100644 --- a/src/accordion/accordion.js +++ b/src/accordion/accordion.js @@ -1,32 +1,32 @@ -angular.module('ui.bootstrap.accordion', ['ui.bootstrap.collapse']) +angular.module('ui.bootstrap.accordion', ['ui.bootstrap.collapse', 'ui.bootstrap.tabindex']) -.constant('accordionConfig', { +.constant('uibAccordionConfig', { closeOthers: true }) -.controller('AccordionController', ['$scope', '$attrs', 'accordionConfig', function ($scope, $attrs, accordionConfig) { - +.controller('UibAccordionController', ['$scope', '$attrs', 'uibAccordionConfig', function($scope, $attrs, accordionConfig) { // This array keeps track of the accordion groups this.groups = []; // Ensure that all the groups in this accordion are closed, unless close-others explicitly says not to this.closeOthers = function(openGroup) { - var closeOthers = angular.isDefined($attrs.closeOthers) ? $scope.$eval($attrs.closeOthers) : accordionConfig.closeOthers; - if ( closeOthers ) { - angular.forEach(this.groups, function (group) { - if ( group !== openGroup ) { + var closeOthers = angular.isDefined($attrs.closeOthers) ? + $scope.$eval($attrs.closeOthers) : accordionConfig.closeOthers; + if (closeOthers) { + angular.forEach(this.groups, function(group) { + if (group !== openGroup) { group.isOpen = false; } }); } }; - + // This is called from the accordion-group directive to add itself to the accordion this.addGroup = function(groupScope) { var that = this; this.groups.push(groupScope); - groupScope.$on('$destroy', function (event) { + groupScope.$on('$destroy', function(event) { that.removeGroup(groupScope); }); }; @@ -34,108 +34,112 @@ angular.module('ui.bootstrap.accordion', ['ui.bootstrap.collapse']) // This is called from the accordion-group directive when to remove itself this.removeGroup = function(group) { var index = this.groups.indexOf(group); - if ( index !== -1 ) { - this.groups.splice(this.groups.indexOf(group), 1); + if (index !== -1) { + this.groups.splice(index, 1); } }; - }]) // The accordion directive simply sets up the directive controller // and adds an accordion CSS class to itself element. -.directive('accordion', function () { +.directive('uibAccordion', function() { return { - restrict:'EA', - controller:'AccordionController', + controller: 'UibAccordionController', + controllerAs: 'accordion', transclude: true, - replace: false, - templateUrl: 'template/accordion/accordion.html' + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/accordion/accordion.html'; + } }; }) // The accordion-group directive indicates a block of html that will expand and collapse in an accordion -.directive('accordionGroup', ['$parse', '$transition', '$timeout', function($parse, $transition, $timeout) { +.directive('uibAccordionGroup', function() { return { - require:'^accordion', // We need this directive to be inside an accordion - restrict:'EA', - transclude:true, // It transcludes the contents of the directive into the template - replace: true, // The element containing the directive will be replaced with the template - templateUrl:'template/accordion/accordion-group.html', - scope:{ heading:'@' }, // Create an isolated scope and interpolate the heading attribute onto this scope - controller: ['$scope', function($scope) { + require: '^uibAccordion', // We need this directive to be inside an accordion + transclude: true, // It transcludes the contents of the directive into the template + restrict: 'A', + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/accordion/accordion-group.html'; + }, + scope: { + heading: '@', // Interpolate the heading attribute onto this scope + panelClass: '@?', // Ditto with panelClass + isOpen: '=?', + isDisabled: '=?' + }, + controller: function() { this.setHeading = function(element) { this.heading = element; }; - }], + }, link: function(scope, element, attrs, accordionCtrl) { - var getIsOpen, setIsOpen; - + element.addClass('panel'); accordionCtrl.addGroup(scope); - scope.isOpen = false; - - if ( attrs.isOpen ) { - getIsOpen = $parse(attrs.isOpen); - setIsOpen = getIsOpen.assign; - - scope.$watch( - function watchIsOpen() { return getIsOpen(scope.$parent); }, - function updateOpen(value) { scope.isOpen = value; } - ); - - scope.isOpen = getIsOpen ? getIsOpen(scope.$parent) : false; - } - + scope.openClass = attrs.openClass || 'panel-open'; + scope.panelClass = attrs.panelClass || 'panel-default'; scope.$watch('isOpen', function(value) { - if ( value ) { + element.toggleClass(scope.openClass, !!value); + if (value) { accordionCtrl.closeOthers(scope); } - if ( setIsOpen ) { - setIsOpen(scope.$parent, value); - } }); + + scope.toggleOpen = function($event) { + if (!scope.isDisabled) { + if (!$event || $event.which === 32) { + scope.isOpen = !scope.isOpen; + } + } + }; + + var id = 'accordiongroup-' + scope.$id + '-' + Math.floor(Math.random() * 10000); + scope.headingId = id + '-tab'; + scope.panelId = id + '-panel'; } }; -}]) +}) // Use accordion-heading below an accordion-group to provide a heading containing HTML -// -// Heading containing HTML - -// -.directive('accordionHeading', function() { +.directive('uibAccordionHeading', function() { return { - restrict: 'EA', transclude: true, // Grab the contents to be used as the heading template: '', // In effect remove this element! replace: true, - require: '^accordionGroup', - compile: function(element, attr, transclude) { - return function link(scope, element, attr, accordionGroupCtrl) { - // Pass the heading to the accordion-group controller - // so that it can be transcluded into the right place in the template - // [The second parameter to transclude causes the elements to be cloned so that they work in ng-repeat] - accordionGroupCtrl.setHeading(transclude(scope, function() {})); - }; + require: '^uibAccordionGroup', + link: function(scope, element, attrs, accordionGroupCtrl, transclude) { + // Pass the heading to the accordion-group controller + // so that it can be transcluded into the right place in the template + // [The second parameter to transclude causes the elements to be cloned so that they work in ng-repeat] + accordionGroupCtrl.setHeading(transclude(scope, angular.noop)); } }; }) // Use in the accordion-group template to indicate where you want the heading to be transcluded // You must provide the property on the accordion-group controller that will hold the transcluded element -// -// ... -// ... -// -.directive('accordionTransclude', function() { +.directive('uibAccordionTransclude', function() { return { - require: '^accordionGroup', - link: function(scope, element, attr, controller) { - scope.$watch(function() { return controller[attr.accordionTransclude]; }, function(heading) { - if ( heading ) { - element.html(''); - element.append(heading); + require: '^uibAccordionGroup', + link: function(scope, element, attrs, controller) { + scope.$watch(function() { return controller[attrs.uibAccordionTransclude]; }, function(heading) { + if (heading) { + var elem = angular.element(element[0].querySelector(getHeaderSelectors())); + elem.html(''); + elem.append(heading); } }); } }; + + function getHeaderSelectors() { + return 'uib-accordion-header,' + + 'data-uib-accordion-header,' + + 'x-uib-accordion-header,' + + 'uib\\:accordion-header,' + + '[uib-accordion-header],' + + '[data-uib-accordion-header],' + + '[x-uib-accordion-header]'; + } }); diff --git a/src/accordion/docs/demo.html b/src/accordion/docs/demo.html index f8627f51ef..2b119b3e5e 100644 --- a/src/accordion/docs/demo.html +++ b/src/accordion/docs/demo.html @@ -1,26 +1,60 @@ - - - Open only one at a time - + - - + + Toggle last panel + Enable / Disable first panel + + + + + + Open only one at a time + + + + This content is straight in the template. - - + + {{group.content}} - - - The body of the accordion group grows to fit the contents - Add Item - {{item}} - - - - I can have markup, too! - - This is just some content to illustrate fancy headings. - - - \ No newline at end of file + + + The body of the uib-accordion group grows to fit the contents + Add Item + {{item}} + + + Hello + + + + Custom template with custom header template + + World + + + Please, to delete your account, click the button below + Delete + + + + I can have markup, too! + + This is just some content to illustrate fancy headings. + + + diff --git a/src/accordion/docs/demo.js b/src/accordion/docs/demo.js index f6604313d0..8922f7ed29 100644 --- a/src/accordion/docs/demo.js +++ b/src/accordion/docs/demo.js @@ -1,14 +1,14 @@ -function AccordionDemoCtrl($scope) { +angular.module('ui.bootstrap.demo').controller('AccordionDemoCtrl', function ($scope) { $scope.oneAtATime = true; $scope.groups = [ { - title: "Dynamic Group Header - 1", - content: "Dynamic Group Body - 1" + title: 'Dynamic Group Header - 1', + content: 'Dynamic Group Body - 1' }, { - title: "Dynamic Group Header - 2", - content: "Dynamic Group Body - 2" + title: 'Dynamic Group Header - 2', + content: 'Dynamic Group Body - 2' } ]; @@ -18,4 +18,10 @@ function AccordionDemoCtrl($scope) { var newItemNo = $scope.items.length + 1; $scope.items.push('Item ' + newItemNo); }; -} + + $scope.status = { + isCustomHeaderOpen: false, + isFirstOpen: true, + isFirstDisabled: false + }; +}); \ No newline at end of file diff --git a/src/accordion/docs/readme.md b/src/accordion/docs/readme.md index 5b3ae93b39..25f790c3fa 100644 --- a/src/accordion/docs/readme.md +++ b/src/accordion/docs/readme.md @@ -1,5 +1,49 @@ The **accordion directive** builds on top of the collapse directive to provide a list of items, with collapsible bodies that are collapsed or expanded by clicking on the item's header. -We can control whether expanding an item will cause the other items to close, using the `close-others` attribute on accordion. +The body of each accordion group is transcluded into the body of the collapsible element. -The body of each accordion group is transcluded in to the body of the collapsible element. \ No newline at end of file +### uib-accordion settings + +* `close-others` + $ + C + _(Default: `true`)_ - + Control whether expanding an item will cause the other items to close. + +* `template-url` + _(Default: `template/accordion/accordion.html`)_ - + Add the ability to override the template used on the component. + +### uib-accordion-group settings + +* `heading` + _(Default: `none`)_ - + The clickable text on the group's header. You need one to be able to click on the header for toggling. + +* `is-disabled` + $ + + _(Default: `false`)_ - + Whether the accordion group is disabled or not. + +* `is-open` + $ + + _(Default: `false`)_ - + Whether accordion group is open or closed. + +* `template-url` + _(Default: `uib/template/accordion/accordion-group.html`)_ - + Add the ability to override the template used on the component. + +### Accordion heading + +Instead of the `heading` attribute on the `uib-accordion-group`, you can use an `uib-accordion-heading` element inside a group that will be used as the group's header. + +If you're using a custom template for the `uib-accordion-group`, you'll need to have an element for the heading to be transcluded into using `uib-accordion-header` (e.g. ``). + +### Known issues + +To use clickable elements within the accordion, you have to override the accordion-group template to use div elements instead of anchor elements, and add `cursor: pointer` in your CSS. This is due to browsers interpreting anchor elements as the target of any click event, which triggers routing when certain elements such as buttons are nested inside the anchor element. + +If custom classes on the accordion-group element are desired, one needs to either modify the template to remove the `ng-class` usage in the accordion-group template and use ng-class on the accordion-group element (not recommended), or use an interpolated expression in the class attribute, i.e. ``. diff --git a/src/accordion/index.js b/src/accordion/index.js new file mode 100644 index 0000000000..491b4f518f --- /dev/null +++ b/src/accordion/index.js @@ -0,0 +1,11 @@ +require('../collapse'); +require('../tabindex'); +require('../../template/accordion/accordion-group.html.js'); +require('../../template/accordion/accordion.html.js'); +require('./accordion'); + +var MODULE_NAME = 'ui.bootstrap.module.accordion'; + +angular.module(MODULE_NAME, ['ui.bootstrap.accordion', 'uib/template/accordion/accordion.html', 'uib/template/accordion/accordion-group.html']); + +module.exports = MODULE_NAME; diff --git a/src/accordion/test/accordion.spec.js b/src/accordion/test/accordion.spec.js new file mode 100644 index 0000000000..bb64a4b2c6 --- /dev/null +++ b/src/accordion/test/accordion.spec.js @@ -0,0 +1,624 @@ +describe('uib-accordion', function() { + var $animate, $scope; + + beforeEach(module('ui.bootstrap.accordion')); + beforeEach(module('ngAnimateMock')); + beforeEach(module('uib/template/accordion/accordion.html')); + beforeEach(module('uib/template/accordion/accordion-group.html')); + + beforeEach(inject(function(_$animate_, $rootScope) { + $animate = _$animate_; + $scope = $rootScope; + })); + + describe('controller', function () { + var ctrl, $element, $attrs; + beforeEach(inject(function($controller) { + $attrs = {}; + ctrl = $controller('UibAccordionController', { $scope: $scope, $attrs: $attrs }); + })); + + describe('addGroup', function() { + it('adds a the specified panel to the collection', function() { + var group1, group2; + ctrl.addGroup(group1 = $scope.$new()); + ctrl.addGroup(group2 = $scope.$new()); + expect(ctrl.groups.length).toBe(2); + expect(ctrl.groups[0]).toBe(group1); + expect(ctrl.groups[1]).toBe(group2); + }); + }); + + describe('closeOthers', function() { + var group1, group2, group3; + beforeEach(function() { + ctrl.addGroup(group1 = { isOpen: true, $on : angular.noop }); + ctrl.addGroup(group2 = { isOpen: true, $on : angular.noop }); + ctrl.addGroup(group3 = { isOpen: true, $on : angular.noop }); + }); + + it('should close other panels if close-others attribute is not defined', function() { + delete $attrs.closeOthers; + ctrl.closeOthers(group2); + expect(group1.isOpen).toBe(false); + expect(group2.isOpen).toBe(true); + expect(group3.isOpen).toBe(false); + }); + + it('should close other panels if close-others attribute is true', function() { + $attrs.closeOthers = 'true'; + ctrl.closeOthers(group3); + expect(group1.isOpen).toBe(false); + expect(group2.isOpen).toBe(false); + expect(group3.isOpen).toBe(true); + }); + + it('should not close other panels if close-others attribute is false', function() { + $attrs.closeOthers = 'false'; + ctrl.closeOthers(group2); + expect(group1.isOpen).toBe(true); + expect(group2.isOpen).toBe(true); + expect(group3.isOpen).toBe(true); + }); + + describe('setting accordionConfig', function() { + var originalCloseOthers; + beforeEach(inject(function(uibAccordionConfig) { + originalCloseOthers = uibAccordionConfig.closeOthers; + uibAccordionConfig.closeOthers = false; + })); + + afterEach(inject(function(uibAccordionConfig) { + // return it to the original value + uibAccordionConfig.closeOthers = originalCloseOthers; + })); + + it('should not close other panels if accordionConfig.closeOthers is false', function() { + ctrl.closeOthers(group2); + expect(group1.isOpen).toBe(true); + expect(group2.isOpen).toBe(true); + expect(group3.isOpen).toBe(true); + }); + }); + }); + + describe('removeGroup', function() { + it('should remove the specified panel', function() { + var group1, group2, group3; + ctrl.addGroup(group1 = $scope.$new()); + ctrl.addGroup(group2 = $scope.$new()); + ctrl.addGroup(group3 = $scope.$new()); + ctrl.removeGroup(group2); + expect(ctrl.groups.length).toBe(2); + expect(ctrl.groups[0]).toBe(group1); + expect(ctrl.groups[1]).toBe(group3); + }); + it('should ignore remove of non-existing panel', function() { + var group1, group2; + ctrl.addGroup(group1 = $scope.$new()); + ctrl.addGroup(group2 = $scope.$new()); + expect(ctrl.groups.length).toBe(2); + ctrl.removeGroup({}); + expect(ctrl.groups.length).toBe(2); + }); + it('should remove a panel when the scope is destroyed', function() { + var group1, group2, group3; + ctrl.addGroup(group1 = $scope.$new()); + ctrl.addGroup(group2 = $scope.$new()); + ctrl.addGroup(group3 = $scope.$new()); + group2.$destroy(); + expect(ctrl.groups.length).toBe(2); + expect(ctrl.groups[0]).toBe(group1); + expect(ctrl.groups[1]).toBe(group3); + }); + }); + }); + + describe('uib-accordion', function() { + var scope, $compile, $templateCache, element; + + beforeEach(inject(function($rootScope, _$compile_, _$templateCache_) { + scope = $rootScope; + $compile = _$compile_; + $templateCache = _$templateCache_; + })); + + it('should be a tablist', function() { + element = $compile('')(scope); + scope.$digest(); + expect(element.html()).toContain('role="tablist"'); + }); + + it('should expose the controller on the view', function() { + $templateCache.put('uib/template/accordion/accordion.html', '{{accordion.text}}'); + + element = $compile('')(scope); + scope.$digest(); + + var ctrl = element.controller('uibAccordion'); + expect(ctrl).toBeDefined(); + + ctrl.text = 'foo'; + scope.$digest(); + + expect(element.html()).toBe('foo'); + }); + + it('should allow custom templates', function() { + $templateCache.put('foo/bar.html', 'baz'); + + element = $compile('')(scope); + scope.$digest(); + expect(element.html()).toBe('baz'); + }); + }); + + describe('uib-accordion-group', function() { + var scope, $compile; + var element, groups; + var findGroupHeading = function(index) { + return groups.eq(index).find('.panel-heading').eq(0); + }; + var findGroupLink = function(index) { + return groups.eq(index).find('.accordion-toggle').eq(0); + }; + var findGroupBody = function(index) { + return groups.eq(index).find('.panel-collapse').eq(0); + }; + + beforeEach(inject(function(_$rootScope_, _$compile_) { + scope = _$rootScope_; + $compile = _$compile_; + })); + + it('should allow custom templates', inject(function($templateCache) { + $templateCache.put('foo/bar.html', 'baz'); + + var tpl = + '' + + '' + + ''; + + element = $compile(tpl)(scope); + scope.$digest(); + expect(element.find('[template-url]').html()).toBe('baz'); + })); + + describe('with static panels', function() { + beforeEach(function() { + spyOn(Math, 'random').and.returnValue(0.1); + var tpl = + '' + + 'Content 1' + + 'Content 2' + + ''; + element = angular.element(tpl); + $compile(element)(scope); + scope.$digest(); + groups = element.find('.panel'); + }); + + afterEach(function() { + element.remove(); + }); + + it('should create accordion panels with content', function() { + expect(groups.length).toEqual(2); + expect(findGroupLink(0).text()).toEqual('title 1'); + expect(findGroupBody(0).text().trim()).toEqual('Content 1'); + expect(findGroupLink(1).text()).toEqual('title 2'); + expect(findGroupBody(1).text().trim()).toEqual('Content 2'); + }); + + it('should change selected element on click', function() { + findGroupLink(0).click(); + scope.$digest(); + expect(findGroupBody(0).scope().isOpen).toBe(true); + expect(findGroupHeading(0).html()).toContain('aria-expanded="true"'); + + findGroupLink(1).click(); + scope.$digest(); + expect(findGroupBody(0).scope().isOpen).toBe(false); + expect(findGroupHeading(0).html()).toContain('aria-expanded="false"'); + expect(findGroupBody(1).scope().isOpen).toBe(true); + expect(findGroupHeading(1).html()).toContain('aria-expanded="true"'); + }); + + it('should toggle element on click', function() { + findGroupLink(0).click(); + scope.$digest(); + expect(findGroupBody(0).scope().isOpen).toBe(true); + expect(groups.eq(0).html()).toContain('aria-hidden="false"'); + + findGroupLink(0).click(); + scope.$digest(); + expect(findGroupBody(0).scope().isOpen).toBe(false); + expect(groups.eq(0).html()).toContain('aria-hidden="true"'); + }); + + it('should add, by default, "panel-open" when opened', function() { + var group = groups.eq(0); + findGroupLink(0).click(); + scope.$digest(); + expect(group).toHaveClass('panel-open'); + + findGroupLink(0).click(); + scope.$digest(); + expect(group).not.toHaveClass('panel-open'); + }); + + it('should toggle element on spacebar when focused', function() { + var group = groups.eq(0); + findGroupLink(0)[0].focus(); + var e = $.Event('keypress'); + e.which = 32; + findGroupLink(0).trigger(e); + + expect(group).toHaveClass('panel-open'); + + e = $.Event('keypress'); + e.which = 32; + findGroupLink(0).trigger(e); + + expect(group).not.toHaveClass('panel-open'); + }); + + it('should not toggle with any other keyCode', function() { + var group = groups.eq(0); + findGroupLink(0)[0].focus(); + var e = $.Event('keypress'); + e.which = 65; + findGroupLink(0).trigger(e); + + expect(group).not.toHaveClass('panel-open'); + }); + + it('should generate an Id for the heading', function() { + var groupScope = findGroupBody(0).scope(); + expect(groupScope.headingId).toEqual('accordiongroup-' + groupScope.$id + '-1000-tab'); + }); + + it('should generate an Id for the panel', function() { + var groupScope = findGroupBody(0).scope(); + expect(groupScope.panelId).toEqual('accordiongroup-' + groupScope.$id + '-1000-panel'); + }); + }); + + describe('with open-class attribute', function() { + beforeEach(function() { + var tpl = + '' + + 'Content 1' + + 'Content 2' + + ''; + element = angular.element(tpl); + $compile(element)(scope); + scope.$digest(); + groups = element.find('.panel'); + }); + + afterEach(function() { + element.remove(); + }); + + it('should add custom-open-class when opened', function() { + var group = groups.eq(0); + findGroupLink(0).click(); + scope.$digest(); + expect(group).toHaveClass('custom-open-class'); + + findGroupLink(0).click(); + scope.$digest(); + expect(group).not.toHaveClass('custom-open-class'); + }); + }); + + describe('with dynamic panels', function() { + var model; + beforeEach(function() { + var tpl = + '' + + '{{group.content}}' + + ''; + element = angular.element(tpl); + model = [ + {name: 'title 1', content: 'Content 1'}, + {name: 'title 2', content: 'Content 2'} + ]; + + $compile(element)(scope); + scope.$digest(); + }); + + it('should have no panels initially', function() { + groups = element.find('.panel'); + expect(groups.length).toEqual(0); + }); + + it('should have a panel for each model item', function() { + scope.groups = model; + scope.$digest(); + groups = element.find('.panel'); + expect(groups.length).toEqual(2); + expect(findGroupLink(0).text()).toEqual('title 1'); + expect(findGroupBody(0).text().trim()).toEqual('Content 1'); + expect(findGroupLink(1).text()).toEqual('title 2'); + expect(findGroupBody(1).text().trim()).toEqual('Content 2'); + }); + + it('should react properly on removing items from the model', function() { + scope.groups = model; + scope.$digest(); + groups = element.find('.panel'); + expect(groups.length).toEqual(2); + + scope.groups.splice(0,1); + scope.$digest(); + groups = element.find('.panel'); + expect(groups.length).toEqual(1); + }); + }); + + describe('is-open attribute', function() { + beforeEach(function() { + var tpl = + '' + + 'Content 1' + + 'Content 2' + + ''; + element = angular.element(tpl); + scope.open = { first: false, second: true }; + $compile(element)(scope); + scope.$digest(); + groups = element.find('.panel'); + }); + + it('should open the panel with isOpen set to true', function() { + expect(findGroupBody(0).scope().isOpen).toBe(false); + expect(findGroupBody(1).scope().isOpen).toBe(true); + }); + + it('should toggle variable on element click', function() { + findGroupLink(0).click(); + scope.$digest(); + expect(scope.open.first).toBe(true); + + findGroupLink(0).click(); + scope.$digest(); + expect(scope.open.second).toBe(false); + }); + }); + + describe('is-open attribute with dynamic content', function() { + beforeEach(function() { + var tpl = + '' + + '{{item}}' + + 'Static content' + + ''; + element = angular.element(tpl); + scope.items = ['Item 1', 'Item 2', 'Item 3']; + scope.open1 = true; + scope.open2 = false; + angular.element(document.body).append(element); + $compile(element)(scope); + scope.$digest(); + $animate.flush(); + groups = element.find('.panel'); + }); + + afterEach(function() { + element.remove(); + }); + + it('should have visible panel body when the group with isOpen set to true', function() { + expect(findGroupBody(0)).toHaveClass('in'); + expect(findGroupBody(1)).not.toHaveClass('in'); + }); + }); + + describe('is-open attribute with dynamic groups', function() { + beforeEach(function() { + var tpl = + '' + + '{{group.content}}' + + ''; + element = angular.element(tpl); + scope.groups = [ + {name: 'title 1', content: 'Content 1', open: false}, + {name: 'title 2', content: 'Content 2', open: true} + ]; + $compile(element)(scope); + scope.$digest(); + + groups = element.find('.panel'); + }); + + it('should have visible group body when the group with isOpen set to true', function() { + expect(findGroupBody(0).scope().isOpen).toBe(false); + expect(findGroupBody(1).scope().isOpen).toBe(true); + }); + + it('should toggle element on click', function() { + findGroupLink(0).click(); + scope.$digest(); + expect(findGroupBody(0).scope().isOpen).toBe(true); + expect(scope.groups[0].open).toBe(true); + + findGroupLink(0).click(); + scope.$digest(); + expect(findGroupBody(0).scope().isOpen).toBe(false); + expect(scope.groups[0].open).toBe(false); + }); + }); + + describe('is-open attribute with custom class', function() { + beforeEach(function() { + var tpl = + '' + + '{{group.content}}' + + ''; + element = angular.element(tpl); + scope.groups = [ + {name: 'title 1', content: 'Content 1', open: false}, + {name: 'title 2', content: 'Content 2', open: true} + ]; + $compile(element)(scope); + scope.$digest(); + + groups = element.find('.panel'); + }); + + it('should add "panel-open" class', function(){ + expect(groups.eq(0)).not.toHaveClass('panel-open'); + expect(groups.eq(1)).toHaveClass('panel-open'); + }); + }); + + describe('`is-disabled` attribute', function() { + var groupBody; + beforeEach(function() { + var tpl = + '' + + 'Content 1' + + ''; + element = angular.element(tpl); + scope.disabled = true; + $compile(element)(scope); + scope.$digest(); + groups = element.find('.panel'); + groupBody = findGroupBody(0); + }); + + it('should open the panel with isOpen set to true', function() { + expect(groupBody.scope().isOpen).toBeFalsy(); + }); + + it('should not toggle if disabled', function() { + findGroupLink(0).click(); + scope.$digest(); + expect(groupBody.scope().isOpen).toBeFalsy(); + }); + + it('should toggle after enabling', function() { + scope.disabled = false; + scope.$digest(); + expect(groupBody.scope().isOpen).toBeFalsy(); + + findGroupLink(0).click(); + scope.$digest(); + expect(groupBody.scope().isOpen).toBeTruthy(); + }); + + it('should have text-muted styling', function() { + expect(findGroupLink(0).find('span:first')).toHaveClass('text-muted'); + }); + }); + + // This is re-used in both the uib-accordion-heading element and the uib-accordion-heading attribute tests + function isDisabledStyleCheck() { + var tpl = + '' + + '' + + 'Heading Element {{x}} ' + + 'Body' + + '' + + ''; + scope.disabled = true; + element = $compile(tpl)(scope); + scope.$digest(); + groups = element.find('.panel'); + + expect(findGroupLink(0).find('span').hasClass('text-muted')).toBe(true); + } + + describe('uib-accordion-heading element', function() { + beforeEach(function() { + var tpl = + '' + + '' + + 'Heading Element {{x}} ' + + 'Body' + + '' + + ''; + element = $compile(tpl)(scope); + scope.$digest(); + groups = element.find('.panel'); + }); + + it('transcludes the content into the heading link', function() { + expect(findGroupLink(0).text()).toBe('Heading Element 123 '); + }); + + it('attaches the same scope to the transcluded heading and body', function() { + expect(findGroupLink(0).scope().$id).toBe(findGroupBody(0).scope().$id); + }); + + it('should wrap the transcluded content in a span', function() { + expect(findGroupLink(0).find('span:first').length).toEqual(1); + }); + + it('should have disabled styling when is-disabled is true', isDisabledStyleCheck); + }); + + describe('uib-accordion-heading attribute', function() { + beforeEach(function() { + var tpl = + '' + + '' + + 'Heading Element {{x}} ' + + 'Body' + + '' + + ''; + element = $compile(tpl)(scope); + scope.$digest(); + groups = element.find('.panel'); + }); + + it('transcludes the content into the heading link', function() { + expect(findGroupLink(0).text()).toBe('Heading Element 123 '); + }); + + it('attaches the same scope to the transcluded heading and body', function() { + expect(findGroupLink(0).scope().$id).toBe(findGroupBody(0).scope().$id); + }); + + it('should have disabled styling when is-disabled is true', isDisabledStyleCheck); + }); + + describe('uib-accordion-heading, with repeating uib-accordion-groups', function() { + it('should clone the uib-accordion-heading for each group', function() { + element = $compile('{{x}}')(scope); + scope.$digest(); + groups = element.find('.panel'); + expect(groups.length).toBe(3); + expect(findGroupLink(0).text()).toBe('1'); + expect(findGroupLink(1).text()).toBe('2'); + expect(findGroupLink(2).text()).toBe('3'); + }); + }); + + describe('uib-accordion-heading attribute, with repeating uib-accordion-groups', function() { + it('should clone the uib-accordion-heading for each group', function() { + element = $compile('{{x}}')(scope); + scope.$digest(); + groups = element.find('.panel'); + expect(groups.length).toBe(3); + expect(findGroupLink(0).text()).toBe('1'); + expect(findGroupLink(1).text()).toBe('2'); + expect(findGroupLink(2).text()).toBe('3'); + }); + }); + + describe('uib-accordion-heading attribute, with custom template', function() { + it('should transclude heading to a template using data-uib-accordion-header', inject(function($templateCache) { + $templateCache.put('foo/bar.html', ''); + + element = $compile('baz')(scope); + scope.$digest(); + groups = element.find('.panel'); + expect(findGroupLink(0).text()).toBe('baz'); + })); + }); + }); +}); diff --git a/src/accordion/test/accordionSpec.js b/src/accordion/test/accordionSpec.js deleted file mode 100644 index cbafd566d8..0000000000 --- a/src/accordion/test/accordionSpec.js +++ /dev/null @@ -1,335 +0,0 @@ -describe('accordion', function () { - var $scope; - - beforeEach(module('ui.bootstrap.accordion')); - beforeEach(module('template/accordion/accordion.html')); - beforeEach(module('template/accordion/accordion-group.html')); - - beforeEach(inject(function ($rootScope) { - $scope = $rootScope; - })); - - describe('controller', function () { - - var ctrl, $element, $attrs; - beforeEach(inject(function($controller) { - $attrs = {}; $element = {}; - ctrl = $controller('AccordionController', { $scope: $scope, $element: $element, $attrs: $attrs }); - })); - - describe('addGroup', function() { - it('adds a the specified group to the collection', function() { - var group1, group2; - ctrl.addGroup(group1 = $scope.$new()); - ctrl.addGroup(group2 = $scope.$new()); - expect(ctrl.groups.length).toBe(2); - expect(ctrl.groups[0]).toBe(group1); - expect(ctrl.groups[1]).toBe(group2); - }); - }); - - describe('closeOthers', function() { - var group1, group2, group3; - beforeEach(function() { - ctrl.addGroup(group1 = { isOpen: true, $on : angular.noop }); - ctrl.addGroup(group2 = { isOpen: true, $on : angular.noop }); - ctrl.addGroup(group3 = { isOpen: true, $on : angular.noop }); - }); - it('should close other groups if close-others attribute is not defined', function() { - delete $attrs.closeOthers; - ctrl.closeOthers(group2); - expect(group1.isOpen).toBe(false); - expect(group2.isOpen).toBe(true); - expect(group3.isOpen).toBe(false); - }); - - it('should close other groups if close-others attribute is true', function() { - $attrs.closeOthers = 'true'; - ctrl.closeOthers(group3); - expect(group1.isOpen).toBe(false); - expect(group2.isOpen).toBe(false); - expect(group3.isOpen).toBe(true); - }); - - it('should not close other groups if close-others attribute is false', function() { - $attrs.closeOthers = 'false'; - ctrl.closeOthers(group2); - expect(group1.isOpen).toBe(true); - expect(group2.isOpen).toBe(true); - expect(group3.isOpen).toBe(true); - }); - - describe('setting accordionConfig', function() { - var originalCloseOthers; - beforeEach(inject(function(accordionConfig) { - originalCloseOthers = accordionConfig.closeOthers; - accordionConfig.closeOthers = false; - })); - afterEach(inject(function(accordionConfig) { - // return it to the original value - accordionConfig.closeOthers = originalCloseOthers; - })); - - it('should not close other groups if accordionConfig.closeOthers is false', function() { - ctrl.closeOthers(group2); - expect(group1.isOpen).toBe(true); - expect(group2.isOpen).toBe(true); - expect(group3.isOpen).toBe(true); - }); - }); - }); - - describe('removeGroup', function() { - it('should remove the specified group', function () { - var group1, group2, group3; - ctrl.addGroup(group1 = $scope.$new()); - ctrl.addGroup(group2 = $scope.$new()); - ctrl.addGroup(group3 = $scope.$new()); - ctrl.removeGroup(group2); - expect(ctrl.groups.length).toBe(2); - expect(ctrl.groups[0]).toBe(group1); - expect(ctrl.groups[1]).toBe(group3); - }); - it('should ignore remove of non-existing group', function () { - var group1, group2; - ctrl.addGroup(group1 = $scope.$new()); - ctrl.addGroup(group2 = $scope.$new()); - expect(ctrl.groups.length).toBe(2); - ctrl.removeGroup({}); - expect(ctrl.groups.length).toBe(2); - }); - }); - }); - - describe('accordion-group', function () { - - var scope, $compile; - var element, groups; - var findGroupLink = function (index) { - return groups.eq(index).find('a').eq(0); - }; - var findGroupBody = function (index) { - return groups.eq(index).find('.accordion-body').eq(0); - }; - - - beforeEach(inject(function(_$rootScope_, _$compile_) { - scope = _$rootScope_; - $compile = _$compile_; - })); - - afterEach(function () { - element = groups = scope = $compile = undefined; - }); - - describe('with static groups', function () { - beforeEach(function () { - var tpl = - "" + - "Content 1" + - "Content 2" + - ""; - element = angular.element(tpl); - angular.element(document.body).append(element); - $compile(element)(scope); - scope.$digest(); - groups = element.find('.accordion-group'); - }); - afterEach(function() { - element.remove(); - }); - - it('should create accordion groups with content', function () { - expect(groups.length).toEqual(2); - expect(findGroupLink(0).text()).toEqual('title 1'); - expect(findGroupBody(0).text().trim()).toEqual('Content 1'); - expect(findGroupLink(1).text()).toEqual('title 2'); - expect(findGroupBody(1).text().trim()).toEqual('Content 2'); - }); - - it('should change selected element on click', function () { - findGroupLink(0).click(); - scope.$digest(); - expect(findGroupBody(0).scope().isOpen).toBe(true); - - findGroupLink(1).click(); - scope.$digest(); - expect(findGroupBody(0).scope().isOpen).toBe(false); - expect(findGroupBody(1).scope().isOpen).toBe(true); - }); - - it('should toggle element on click', function() { - findGroupLink(0).click(); - scope.$digest(); - expect(findGroupBody(0).scope().isOpen).toBe(true); - findGroupLink(0).click(); - scope.$digest(); - expect(findGroupBody(0).scope().isOpen).toBe(false); - }); - }); - - describe('with dynamic groups', function () { - var model; - beforeEach(function () { - var tpl = - "" + - "{{group.content}}" + - ""; - element = angular.element(tpl); - model = [ - {name: 'title 1', content: 'Content 1'}, - {name: 'title 2', content: 'Content 2'} - ]; - - $compile(element)(scope); - scope.$digest(); - }); - - it('should have no groups initially', function () { - groups = element.find('.accordion-group'); - expect(groups.length).toEqual(0); - }); - - it('should have a group for each model item', function() { - scope.groups = model; - scope.$digest(); - groups = element.find('.accordion-group'); - expect(groups.length).toEqual(2); - expect(findGroupLink(0).text()).toEqual('title 1'); - expect(findGroupBody(0).text().trim()).toEqual('Content 1'); - expect(findGroupLink(1).text()).toEqual('title 2'); - expect(findGroupBody(1).text().trim()).toEqual('Content 2'); - }); - - it('should react properly on removing items from the model', function () { - scope.groups = model; - scope.$digest(); - groups = element.find('.accordion-group'); - expect(groups.length).toEqual(2); - - scope.groups.splice(0,1); - scope.$digest(); - groups = element.find('.accordion-group'); - expect(groups.length).toEqual(1); - }); - }); - - describe('is-open attribute', function() { - beforeEach(function () { - var tpl = - "" + - "Content 1" + - "Content 2" + - ""; - element = angular.element(tpl); - scope.open1 = false; - scope.open2 = true; - $compile(element)(scope); - scope.$digest(); - groups = element.find('.accordion-group'); - }); - - it('should open the group with isOpen set to true', function () { - expect(findGroupBody(0).scope().isOpen).toBe(false); - expect(findGroupBody(1).scope().isOpen).toBe(true); - }); - }); - - describe('is-open attribute with dynamic content', function() { - beforeEach(function () { - var tpl = - "" + - "{{item}}" + - "Static content" + - ""; - element = angular.element(tpl); - scope.items = ['Item 1', 'Item 2', 'Item 3']; - scope.open1 = true; - scope.open2 = false; - angular.element(document.body).append(element); - $compile(element)(scope); - scope.$digest(); - groups = element.find('.accordion-group'); - }); - - afterEach(function() { - element.remove(); - }); - - it('should have visible group body when the group with isOpen set to true', function () { - expect(findGroupBody(0)[0].clientHeight).not.toBe(0); - expect(findGroupBody(1)[0].clientHeight).toBe(0); - }); - }); - - describe('accordion-heading element', function() { - beforeEach(function() { - var tpl = - '' + - '' + - 'Heading Element {{x}} ' + - 'Body' + - '' + - ''; - element = $compile(tpl)(scope); - scope.$digest(); - groups = element.find('.accordion-group'); - }); - it('transcludes the content into the heading link', function() { - expect(findGroupLink(0).text()).toBe('Heading Element 123 '); - }); - it('attaches the same scope to the transcluded heading and body', function() { - expect(findGroupLink(0).find('span').scope().$id).toBe(findGroupBody(0).find('span').scope().$id); - }); - - }); - - describe('accordion-heading attribute', function() { - beforeEach(function() { - var tpl = - '' + - '' + - 'Heading Element {{x}} ' + - 'Body' + - '' + - ''; - element = $compile(tpl)(scope); - scope.$digest(); - groups = element.find('.accordion-group'); - }); - it('transcludes the content into the heading link', function() { - expect(findGroupLink(0).text()).toBe('Heading Element 123 '); - }); - it('attaches the same scope to the transcluded heading and body', function() { - expect(findGroupLink(0).find('span').scope().$id).toBe(findGroupBody(0).find('span').scope().$id); - }); - - }); - - describe('accordion-heading, with repeating accordion-groups', function() { - it('should clone the accordion-heading for each group', function() { - element = $compile('{{x}}')(scope); - scope.$digest(); - groups = element.find('.accordion-group'); - expect(groups.length).toBe(3); - expect(findGroupLink(0).text()).toBe('1'); - expect(findGroupLink(1).text()).toBe('2'); - expect(findGroupLink(2).text()).toBe('3'); - }); - }); - - - describe('accordion-heading attribute, with repeating accordion-groups', function() { - it('should clone the accordion-heading for each group', function() { - element = $compile('{{x}}')(scope); - scope.$digest(); - groups = element.find('.accordion-group'); - expect(groups.length).toBe(3); - expect(findGroupLink(0).text()).toBe('1'); - expect(findGroupLink(1).text()).toBe('2'); - expect(findGroupLink(2).text()).toBe('3'); - }); - }); - - }); -}); diff --git a/src/alert/alert.js b/src/alert/alert.js index 07f68dde5b..73387e55a5 100644 --- a/src/alert/alert.js +++ b/src/alert/alert.js @@ -1,15 +1,34 @@ -angular.module("ui.bootstrap.alert", []).directive('alert', function () { +angular.module('ui.bootstrap.alert', []) + +.controller('UibAlertController', ['$scope', '$element', '$attrs', '$interpolate', '$timeout', function($scope, $element, $attrs, $interpolate, $timeout) { + $scope.closeable = !!$attrs.close; + $element.addClass('alert'); + $attrs.$set('role', 'alert'); + if ($scope.closeable) { + $element.addClass('alert-dismissible'); + } + + var dismissOnTimeout = angular.isDefined($attrs.dismissOnTimeout) ? + $interpolate($attrs.dismissOnTimeout)($scope.$parent) : null; + + if (dismissOnTimeout) { + $timeout(function() { + $scope.close(); + }, parseInt(dismissOnTimeout, 10)); + } +}]) + +.directive('uibAlert', function() { return { - restrict:'EA', - templateUrl:'template/alert/alert.html', - transclude:true, - replace:true, + controller: 'UibAlertController', + controllerAs: 'alert', + restrict: 'A', + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/alert/alert.html'; + }, + transclude: true, scope: { - type: '=', close: '&' - }, - link: function(scope, iElement, iAttrs, controller) { - scope.closeable = "close" in iAttrs; } }; }); diff --git a/src/alert/docs/demo.html b/src/alert/docs/demo.html index a750374f37..b599f48091 100644 --- a/src/alert/docs/demo.html +++ b/src/alert/docs/demo.html @@ -1,4 +1,9 @@ - {{alert.msg}} - Add Alert + + + {{alert.msg}} + A happy alert! + Add Alert diff --git a/src/alert/docs/demo.js b/src/alert/docs/demo.js index 290ee9d001..267a3ae58e 100644 --- a/src/alert/docs/demo.js +++ b/src/alert/docs/demo.js @@ -1,15 +1,14 @@ -function AlertDemoCtrl($scope) { +angular.module('ui.bootstrap.demo').controller('AlertDemoCtrl', function ($scope) { $scope.alerts = [ - { type: 'error', msg: 'Oh snap! Change a few things up and try submitting again.' }, + { type: 'danger', msg: 'Oh snap! Change a few things up and try submitting again.' }, { type: 'success', msg: 'Well done! You successfully read this important alert message.' } ]; $scope.addAlert = function() { - $scope.alerts.push({msg: "Another alert!"}); + $scope.alerts.push({msg: 'Another alert!'}); }; $scope.closeAlert = function(index) { $scope.alerts.splice(index, 1); }; - -} \ No newline at end of file +}); \ No newline at end of file diff --git a/src/alert/docs/readme.md b/src/alert/docs/readme.md index fb8029a5a3..cf0bcad1ab 100644 --- a/src/alert/docs/readme.md +++ b/src/alert/docs/readme.md @@ -1,5 +1,15 @@ -Alert is an AngularJS-version of bootstrap's alert. +This directive can be used both to generate alerts from static and dynamic model data (using the `ng-repeat` directive). -This directive can be used to generate alerts from the dynamic model data (using the ng-repeat directive); +### uib-alert settings -The presence of the "close" attribute determines if a close button is displayed +* `close()` + $ - + A callback function that gets fired when an `alert` is closed. If the attribute exists, a close button is displayed as well. + +* `dismiss-on-timeout` + _(Default: `none`)_ - + Takes the number of milliseconds that specify the timeout duration, after which the alert will be closed. This attribute requires the presence of the `close` attribute. + +* `template-url` + _(Default: `uib/template/alert/alert.html`)_ - + Add the ability to override the template used in the component. diff --git a/src/alert/index.js b/src/alert/index.js new file mode 100644 index 0000000000..f5a50975d1 --- /dev/null +++ b/src/alert/index.js @@ -0,0 +1,8 @@ +require('../../template/alert/alert.html.js'); +require('./alert'); + +var MODULE_NAME = 'ui.bootstrap.module.alert'; + +angular.module(MODULE_NAME, ['ui.bootstrap.alert', 'uib/template/alert/alert.html']); + +module.exports = MODULE_NAME; diff --git a/src/alert/test/alert.spec.js b/src/alert/test/alert.spec.js index 0ffbc241a7..bc87c7f7ad 100644 --- a/src/alert/test/alert.spec.js +++ b/src/alert/test/alert.spec.js @@ -1,22 +1,22 @@ -describe("alert", function () { - - var scope, ctrl, model, $compile; - var element; +describe('uib-alert', function() { + var element, scope, $compile, $templateCache, $timeout; beforeEach(module('ui.bootstrap.alert')); - beforeEach(module('template/alert/alert.html')); - - beforeEach(inject(function ($rootScope, _$compile_, $controller) { + beforeEach(module('uib/template/alert/alert.html')); + beforeEach(inject(function($rootScope, _$compile_, _$templateCache_, _$timeout_) { scope = $rootScope; $compile = _$compile_; + $templateCache = _$templateCache_; + $timeout = _$timeout_; element = angular.element( - "" + - "{{alert.msg}}" + - "" + - ""); + '' + + '{{alert.msg}}' + + '' + + ''); scope.alerts = [ { msg:'foo', type:'success'}, @@ -32,48 +32,98 @@ describe("alert", function () { } function findCloseButton(index) { - return element.find('.alert button').eq(index); + return element.find('.close').eq(index); + } + + function findContent(index) { + return element.find('div[ng-transclude]').eq(index); } - it("should generate alerts using ng-repeat", function () { + it('should expose the controller to the view', function() { + $templateCache.put('uib/template/alert/alert.html', '{{alert.text}}'); + + element = $compile('')(scope); + scope.$digest(); + + var ctrl = element.controller('uib-alert'); + expect(ctrl).toBeDefined(); + + ctrl.text = 'foo'; + scope.$digest(); + + expect(element.html()).toBe('foo'); + }); + + it('should support custom templates', function() { + $templateCache.put('foo/bar.html', 'baz'); + + element = $compile('')(scope); + scope.$digest(); + + expect(element.html()).toBe('baz'); + }); + + it('should generate alerts using ng-repeat', function() { var alerts = createAlerts(); expect(alerts.length).toEqual(3); }); - it("should use correct classes for different alert types", function () { + it('should show the alert content', function() { var alerts = createAlerts(); - expect(alerts.eq(0)).toHaveClass('alert-success'); - expect(alerts.eq(1)).toHaveClass('alert-error'); - //defaults - expect(alerts.eq(2)).toHaveClass('alert'); - expect(alerts.eq(2)).not.toHaveClass('alert-info'); - expect(alerts.eq(2)).not.toHaveClass('alert-block'); + for (var i = 0, n = alerts.length; i < n; i++) { + expect(findContent(i).text()).toBe(scope.alerts[i].msg); + } }); - it("should fire callback when closed", function () { + it('should show close buttons and have the dismissible class', function() { + var alerts = createAlerts(); + + for (var i = 0, n = alerts.length; i < n; i++) { + expect(findCloseButton(i).css('display')).not.toBe('none'); + expect(alerts.eq(i)).toHaveClass('alert-dismissible'); + } + }); + it('should fire callback when closed', function() { var alerts = createAlerts(); - scope.$apply(function () { + scope.$apply(function() { scope.removeAlert = jasmine.createSpy(); }); + expect(findCloseButton(0).css('display')).not.toBe('none'); findCloseButton(1).click(); + expect(scope.removeAlert).toHaveBeenCalledWith(1); }); - it('should not show close buttons if no close callback specified', function () { - var element = $compile('No close')(scope); + it('should not show close button and have the dismissible class if no close callback specified', function() { + element = $compile('No close')(scope); scope.$digest(); - expect(findCloseButton(0).length).toEqual(0); + expect(findCloseButton(0)).toBeHidden(); + expect(element).not.toHaveClass('alert-dismissible'); }); - it('it should be possible to add additional classes for alert', function () { - var element = $compile('Default alert!')(scope); + it('should close automatically if dismiss-on-timeout is defined on the element', function() { + scope.removeAlert = jasmine.createSpy(); + $compile('Default alert!')(scope); scope.$digest(); - expect(element).toHaveClass('alert-block'); - expect(element).toHaveClass('alert-info'); + + $timeout.flush(); + expect(scope.removeAlert).toHaveBeenCalled(); }); + it('should not close immediately with a dynamic dismiss-on-timeout', function() { + scope.removeAlert = jasmine.createSpy(); + scope.dismissTime = 500; + $compile('Default alert!')(scope); + scope.$digest(); + + $timeout.flush(100); + expect(scope.removeAlert).not.toHaveBeenCalled(); + + $timeout.flush(500); + expect(scope.removeAlert).toHaveBeenCalled(); + }); }); diff --git a/src/buttons/buttons.js b/src/buttons/buttons.js index c7a62d9332..4d28026e9a 100644 --- a/src/buttons/buttons.js +++ b/src/buttons/buttons.js @@ -1,68 +1,94 @@ angular.module('ui.bootstrap.buttons', []) - .constant('buttonConfig', { - activeClass:'active', - toggleEvent:'click' - }) +.constant('uibButtonConfig', { + activeClass: 'active', + toggleEvent: 'click' +}) - .directive('btnRadio', ['buttonConfig', function (buttonConfig) { - var activeClass = buttonConfig.activeClass || 'active'; - var toggleEvent = buttonConfig.toggleEvent || 'click'; +.controller('UibButtonsController', ['uibButtonConfig', function(buttonConfig) { + this.activeClass = buttonConfig.activeClass || 'active'; + this.toggleEvent = buttonConfig.toggleEvent || 'click'; +}]) +.directive('uibBtnRadio', ['$parse', function($parse) { return { + require: ['uibBtnRadio', 'ngModel'], + controller: 'UibButtonsController', + controllerAs: 'buttons', + link: function(scope, element, attrs, ctrls) { + var buttonsCtrl = ctrls[0], ngModelCtrl = ctrls[1]; + var uncheckableExpr = $parse(attrs.uibUncheckable); - require:'ngModel', - link:function (scope, element, attrs, ngModelCtrl) { + element.find('input').css({display: 'none'}); //model -> UI - ngModelCtrl.$render = function () { - element.toggleClass(activeClass, angular.equals(ngModelCtrl.$modelValue, scope.$eval(attrs.btnRadio))); + ngModelCtrl.$render = function() { + element.toggleClass(buttonsCtrl.activeClass, angular.equals(ngModelCtrl.$modelValue, scope.$eval(attrs.uibBtnRadio))); }; //ui->model - element.bind(toggleEvent, function () { - if (!element.hasClass(activeClass)) { - scope.$apply(function () { - ngModelCtrl.$setViewValue(scope.$eval(attrs.btnRadio)); + element.on(buttonsCtrl.toggleEvent, function() { + if (attrs.disabled) { + return; + } + + var isActive = element.hasClass(buttonsCtrl.activeClass); + + if (!isActive || angular.isDefined(attrs.uncheckable)) { + scope.$apply(function() { + ngModelCtrl.$setViewValue(isActive ? null : scope.$eval(attrs.uibBtnRadio)); ngModelCtrl.$render(); }); } }); + + if (attrs.uibUncheckable) { + scope.$watch(uncheckableExpr, function(uncheckable) { + attrs.$set('uncheckable', uncheckable ? '' : undefined); + }); + } } }; }]) - .directive('btnCheckbox', ['buttonConfig', function (buttonConfig) { - - var activeClass = buttonConfig.activeClass || 'active'; - var toggleEvent = buttonConfig.toggleEvent || 'click'; - +.directive('uibBtnCheckbox', function() { return { - require:'ngModel', - link:function (scope, element, attrs, ngModelCtrl) { + require: ['uibBtnCheckbox', 'ngModel'], + controller: 'UibButtonsController', + controllerAs: 'button', + link: function(scope, element, attrs, ctrls) { + var buttonsCtrl = ctrls[0], ngModelCtrl = ctrls[1]; + + element.find('input').css({display: 'none'}); function getTrueValue() { - var trueValue = scope.$eval(attrs.btnCheckboxTrue); - return angular.isDefined(trueValue) ? trueValue : true; + return getCheckboxValue(attrs.btnCheckboxTrue, true); } function getFalseValue() { - var falseValue = scope.$eval(attrs.btnCheckboxFalse); - return angular.isDefined(falseValue) ? falseValue : false; + return getCheckboxValue(attrs.btnCheckboxFalse, false); + } + + function getCheckboxValue(attribute, defaultValue) { + return angular.isDefined(attribute) ? scope.$eval(attribute) : defaultValue; } //model -> UI - ngModelCtrl.$render = function () { - element.toggleClass(activeClass, angular.equals(ngModelCtrl.$modelValue, getTrueValue())); + ngModelCtrl.$render = function() { + element.toggleClass(buttonsCtrl.activeClass, angular.equals(ngModelCtrl.$modelValue, getTrueValue())); }; //ui->model - element.bind(toggleEvent, function () { - scope.$apply(function () { - ngModelCtrl.$setViewValue(element.hasClass(activeClass) ? getFalseValue() : getTrueValue()); + element.on(buttonsCtrl.toggleEvent, function() { + if (attrs.disabled) { + return; + } + + scope.$apply(function() { + ngModelCtrl.$setViewValue(element.hasClass(buttonsCtrl.activeClass) ? getFalseValue() : getTrueValue()); ngModelCtrl.$render(); }); }); } }; -}]); \ No newline at end of file +}); diff --git a/src/buttons/docs/demo.html b/src/buttons/docs/demo.html index 2093446d04..1ceeabbc60 100644 --- a/src/buttons/docs/demo.html +++ b/src/buttons/docs/demo.html @@ -1,21 +1,32 @@ Single toggle {{singleModel}} - + Single Toggle Checkbox - {{checkModel}} + Model: {{checkModel}} + Results: {{checkResults}} - Left - Middle - Right + Left + Middle + Right - Radio - {{radioModel}} + Radio & Uncheckable Radio + {{radioModel || 'null'}} - Left - Middle - Right + Left + Middle + Right - \ No newline at end of file + + Left + Middle + Right + + + + Toggle uncheckable + + + diff --git a/src/buttons/docs/demo.js b/src/buttons/docs/demo.js index b04546a810..3d5760fbec 100644 --- a/src/buttons/docs/demo.js +++ b/src/buttons/docs/demo.js @@ -1,5 +1,4 @@ -var ButtonsCtrl = function ($scope) { - +angular.module('ui.bootstrap.demo').controller('ButtonsCtrl', function ($scope) { $scope.singleModel = 1; $scope.radioModel = 'Middle'; @@ -9,4 +8,15 @@ var ButtonsCtrl = function ($scope) { middle: true, right: false }; -}; \ No newline at end of file + + $scope.checkResults = []; + + $scope.$watchCollection('checkModel', function () { + $scope.checkResults = []; + angular.forEach($scope.checkModel, function (value, key) { + if (value) { + $scope.checkResults.push(key); + } + }); + }); +}); \ No newline at end of file diff --git a/src/buttons/docs/readme.md b/src/buttons/docs/readme.md index 73ef6003d1..e5ff9629bb 100644 --- a/src/buttons/docs/readme.md +++ b/src/buttons/docs/readme.md @@ -1,2 +1,50 @@ -There are 2 directives that can make a group of buttons to behave like a set of checkboxes or radio buttons. +With the buttons directive, we can make a group of buttons behave like a set of checkboxes (`uib-btn-checkbox`) or behave like a set of radio buttons (`uib-btn-radio`). +### uib-btn-checkbox settings + +* `btn-checkbox-false` + _(Default: `false`)_ - + Sets the value for the unchecked status. + +* `btn-checkbox-true` + _(Default: `true`)_ - + Sets the value for the checked status. + +* `ng-model` + $ + - + Model where we set the checkbox status. By default `true` or `false`. + +### uib-btn-radio settings + +* `ng-model` + $ + - + Model where we set the radio status. All radio buttons in a group should use the same `ng-model`. + +* `uib-btn-radio` - + $ + Value to assign to the `ng-model` if we check this radio button. + +* `uib-uncheckable` + $ + _(Default: `null`)_ - + An expression that evaluates to a truthy or falsy value that determines whether the `uncheckable` attribute is present. + +* `uncheckable` + B - + Whether a radio button can be unchecked or not. + +### Additional settings `uibButtonConfig` + +* `activeClass` + _(Default: `active`)_ - + Class to apply to the checked buttons. + +* `toggleEvent` + _(Default: `click`)_ - + Event used to toggle the buttons. + +### Known issues + +To use tooltips or popovers on elements within a `btn-group`, set the tooltip/popover `appendToBody` option to `true`. This is due to Bootstrap CSS styling. See [here](http://getbootstrap.com/components/#btn-groups) for more information. diff --git a/src/buttons/index.js b/src/buttons/index.js new file mode 100644 index 0000000000..181f258adc --- /dev/null +++ b/src/buttons/index.js @@ -0,0 +1,7 @@ +require('./buttons'); + +var MODULE_NAME = 'ui.bootstrap.module.buttons'; + +angular.module(MODULE_NAME, ['ui.bootstrap.buttons']); + +module.exports = MODULE_NAME; diff --git a/src/buttons/test/buttons.spec.js b/src/buttons/test/buttons.spec.js index 8fbfa1501e..de2945f45a 100644 --- a/src/buttons/test/buttons.spec.js +++ b/src/buttons/test/buttons.spec.js @@ -1,25 +1,35 @@ -describe('buttons', function () { +describe('buttons', function() { var $scope, $compile; beforeEach(module('ui.bootstrap.buttons')); - beforeEach(inject(function (_$rootScope_, _$compile_) { + beforeEach(inject(function(_$rootScope_, _$compile_) { $scope = _$rootScope_; $compile = _$compile_; })); - describe('checkbox', function () { - - var compileButton = function (markup, scope) { + describe('checkbox', function() { + var compileButton = function(markup, scope) { var el = $compile(markup)(scope); scope.$digest(); return el; }; + it('should expose the controller to the view', inject(function($templateCache) { + var btn = compileButton('{{button.text}}', $scope); + var ctrl = btn.controller('uibBtnCheckbox'); + expect(ctrl).toBeDefined(); + + ctrl.text = 'foo'; + $scope.$digest(); + + expect(btn.html()).toBe('foo'); + })); + //model -> UI - it('should work correctly with default model values', function () { + it('should work correctly with default model values', function() { $scope.model = false; - var btn = compileButton('click', $scope); + var btn = compileButton('click', $scope); expect(btn).not.toHaveClass('active'); $scope.model = true; @@ -27,9 +37,9 @@ describe('buttons', function () { expect(btn).toHaveClass('active'); }); - it('should bind custom model values', function () { + it('should bind custom model values', function() { $scope.model = 1; - var btn = compileButton('click', $scope); + var btn = compileButton('click', $scope); expect(btn).toHaveClass('active'); $scope.model = 0; @@ -38,9 +48,9 @@ describe('buttons', function () { }); //UI-> model - it('should toggle default model values on click', function () { + it('should toggle default model values on click', function() { $scope.model = false; - var btn = compileButton('click', $scope); + var btn = compileButton('click', $scope); btn.click(); expect($scope.model).toEqual(true); @@ -51,9 +61,9 @@ describe('buttons', function () { expect(btn).not.toHaveClass('active'); }); - it('should toggle custom model values on click', function () { + it('should toggle custom model values on click', function() { $scope.model = 0; - var btn = compileButton('click', $scope); + var btn = compileButton('click', $scope); btn.click(); expect($scope.model).toEqual(1); @@ -64,11 +74,11 @@ describe('buttons', function () { expect(btn).not.toHaveClass('active'); }); - it('should monitor true / false value changes - issue 666', function () { + it('should monitor true / false value changes - issue 666', function() { $scope.model = 1; $scope.trueVal = 1; - var btn = compileButton('click', $scope); + var btn = compileButton('click', $scope); expect(btn).toHaveClass('active'); expect($scope.model).toEqual(1); @@ -80,19 +90,98 @@ describe('buttons', function () { expect(btn).toHaveClass('active'); expect($scope.model).toEqual(2); }); - }); - describe('radio', function () { + it('should not toggle when disabled - issue 4013', function() { + $scope.model = 1; + $scope.falseVal = 0; + var btn = compileButton('click', $scope); + + expect(btn).not.toHaveClass('active'); + expect($scope.model).toEqual(1); + + btn.click(); + + expect(btn).not.toHaveClass('active'); + + $scope.$digest(); + + expect(btn).not.toHaveClass('active'); + }); + + describe('setting buttonConfig', function() { + var uibButtonConfig, originalActiveClass, originalToggleEvent; + + beforeEach(inject(function(_uibButtonConfig_) { + uibButtonConfig = _uibButtonConfig_; + originalActiveClass = uibButtonConfig.activeClass; + originalToggleEvent = uibButtonConfig.toggleEvent; + uibButtonConfig.activeClass = false; + uibButtonConfig.toggleEvent = false; + })); + + afterEach(function() { + // return it to the original value + uibButtonConfig.activeClass = originalActiveClass; + uibButtonConfig.toggleEvent = originalToggleEvent; + }); + + it('should use default config when buttonConfig.activeClass and buttonConfig.toggleEvent is false', function() { + $scope.model = false; + var btn = compileButton('click', $scope); + expect(btn).not.toHaveClass('active'); + + $scope.model = true; + $scope.$digest(); + expect(btn).toHaveClass('active'); + }); + + it('should be able to use a different active class', function() { + uibButtonConfig.activeClass = 'foo'; + $scope.model = false; + var btn = compileButton('click', $scope); + expect(btn).not.toHaveClass('foo'); - var compileButtons = function (markup, scope) { + $scope.model = true; + $scope.$digest(); + expect(btn).toHaveClass('foo'); + }); + + it('should be able to use a different toggle event', function() { + uibButtonConfig.toggleEvent = 'mouseenter'; + $scope.model = false; + var btn = compileButton('click', $scope); + expect(btn).not.toHaveClass('active'); + + btn.trigger('mouseenter'); + + $scope.$digest(); + expect(btn).toHaveClass('active'); + }); + }); + + }); + + describe('radio', function() { + var compileButtons = function(markup, scope) { var el = $compile(''+markup+'')(scope); scope.$digest(); return el.find('button'); }; + it('should expose the controller to the view', inject(function($templateCache) { + var btn = compileButtons('{{buttons.text}}', $scope); + var ctrl = btn.controller('uibBtnRadio'); + expect(ctrl).toBeDefined(); + + ctrl.text = 'foo'; + $scope.$digest(); + + expect(btn.html()).toBe('foo'); + })); + //model -> UI - it('should work correctly set active class based on model', function () { - var btns = compileButtons('click1click2', $scope); + it('should set active class based on model', function() { + var btns = compileButtons('click1click2', $scope); expect(btns.eq(0)).not.toHaveClass('active'); expect(btns.eq(1)).not.toHaveClass('active'); @@ -103,8 +192,8 @@ describe('buttons', function () { }); //UI->model - it('should work correctly set active class based on model', function () { - var btns = compileButtons('click1click2', $scope); + it('should set active class via click', function() { + var btns = compileButtons('click1click2', $scope); expect($scope.model).toBeUndefined(); btns.eq(0).click(); @@ -118,23 +207,160 @@ describe('buttons', function () { expect(btns.eq(0)).not.toHaveClass('active'); }); - it('should watch btn-radio values and update state accordingly', function () { - $scope.values = ["value1", "value2"]; + it('should watch uib-btn-radio values and update state accordingly', function() { + $scope.values = ['value1', 'value2']; - var btns = compileButtons('click1click2', $scope); + var btns = compileButtons('click1click2', $scope); expect(btns.eq(0)).not.toHaveClass('active'); expect(btns.eq(1)).not.toHaveClass('active'); - $scope.model = "value2"; + $scope.model = 'value2'; + $scope.$digest(); + expect(btns.eq(0)).not.toHaveClass('active'); + expect(btns.eq(1)).toHaveClass('active'); + + $scope.values[1] = 'value3'; + $scope.model = 'value3'; $scope.$digest(); expect(btns.eq(0)).not.toHaveClass('active'); expect(btns.eq(1)).toHaveClass('active'); + }); + + it('should do nothing when clicking an active radio', function() { + $scope.model = 1; + var btns = compileButtons('click1click2', $scope); + expect(btns.eq(0)).toHaveClass('active'); + expect(btns.eq(1)).not.toHaveClass('active'); + + btns.eq(0).click(); + $scope.$digest(); + expect(btns.eq(0)).toHaveClass('active'); + expect(btns.eq(1)).not.toHaveClass('active'); + }); + + it('should not toggle when disabled - issue 4013', function() { + $scope.model = 1; + var btns = compileButtons('click1click2', $scope); + + expect(btns.eq(0)).toHaveClass('active'); + expect(btns.eq(1)).not.toHaveClass('active'); + + btns.eq(1).click(); + + expect(btns.eq(0)).toHaveClass('active'); + expect(btns.eq(1)).not.toHaveClass('active'); - $scope.values[1] = "value3"; - $scope.model = "value3"; $scope.$digest(); + + expect(btns.eq(0)).toHaveClass('active'); + expect(btns.eq(1)).not.toHaveClass('active'); + }); + + it('should handle string values in uib-btn-radio value', function() { + $scope.model = 'Two'; + var btns = compileButtons('click1click2', $scope); + expect(btns.eq(0)).not.toHaveClass('active'); expect(btns.eq(1)).toHaveClass('active'); + + btns.eq(0).click(); + expect(btns.eq(0)).toHaveClass('active'); + expect(btns.eq(1)).not.toHaveClass('active'); + expect($scope.model).toEqual('One'); + + $scope.$digest(); + + expect(btns.eq(0)).toHaveClass('active'); + expect(btns.eq(1)).not.toHaveClass('active'); + expect($scope.model).toEqual('One'); + }); + + describe('uncheckable', function() { + //model -> UI + it('should set active class based on model', function() { + var btns = compileButtons('click1click2', $scope); + expect(btns.eq(0)).not.toHaveClass('active'); + expect(btns.eq(1)).not.toHaveClass('active'); + + $scope.model = 2; + $scope.$digest(); + expect(btns.eq(0)).not.toHaveClass('active'); + expect(btns.eq(1)).toHaveClass('active'); + }); + + //UI->model + it('should unset active class via click', function() { + var btns = compileButtons('click1click2', $scope); + expect($scope.model).toBeUndefined(); + + btns.eq(0).click(); + expect($scope.model).toEqual(1); + expect(btns.eq(0)).toHaveClass('active'); + expect(btns.eq(1)).not.toHaveClass('active'); + + btns.eq(0).click(); + expect($scope.model).toBeNull(); + expect(btns.eq(1)).not.toHaveClass('active'); + expect(btns.eq(0)).not.toHaveClass('active'); + }); + + it('should watch uib-btn-radio values and update state', function() { + $scope.values = ['value1', 'value2']; + + var btns = compileButtons('click1click2', $scope); + expect(btns.eq(0)).not.toHaveClass('active'); + expect(btns.eq(1)).not.toHaveClass('active'); + + $scope.model = 'value2'; + $scope.$digest(); + expect(btns.eq(0)).not.toHaveClass('active'); + expect(btns.eq(1)).toHaveClass('active'); + + $scope.model = undefined; + $scope.$digest(); + expect(btns.eq(0)).not.toHaveClass('active'); + expect(btns.eq(1)).not.toHaveClass('active'); + }); + }); + + describe('uibUncheckable', function() { + it('should set uncheckable', function() { + $scope.uncheckable = false; + var btns = compileButtons('click1click2', $scope); + expect(btns.eq(0).attr('uncheckable')).toBeUndefined(); + expect(btns.eq(1).attr('uncheckable')).toBeUndefined(); + + expect($scope.model).toBeUndefined(); + + btns.eq(0).click(); + expect($scope.model).toEqual(1); + + btns.eq(0).click(); + expect($scope.model).toEqual(1); + + btns.eq(1).click(); + expect($scope.model).toEqual(2); + + btns.eq(1).click(); + expect($scope.model).toEqual(2); + + $scope.uncheckable = true; + $scope.$digest(); + expect(btns.eq(0).attr('uncheckable')).toBeUndefined(); + expect(btns.eq(1).attr('uncheckable')).toBeDefined(); + + btns.eq(0).click(); + expect($scope.model).toEqual(1); + + btns.eq(0).click(); + expect($scope.model).toEqual(1); + + btns.eq(1).click(); + expect($scope.model).toEqual(2); + + btns.eq(1).click(); + expect($scope.model).toBeNull(); + }); }); }); -}); \ No newline at end of file +}); diff --git a/src/carousel/carousel.css b/src/carousel/carousel.css new file mode 100644 index 0000000000..061b3046d5 --- /dev/null +++ b/src/carousel/carousel.css @@ -0,0 +1,4 @@ +.ng-animate.item:not(.left):not(.right) { + -webkit-transition: 0s ease-in-out left; + transition: 0s ease-in-out left +} \ No newline at end of file diff --git a/src/carousel/carousel.js b/src/carousel/carousel.js index b4681e80de..aaa04f8a95 100644 --- a/src/carousel/carousel.js +++ b/src/carousel/carousel.js @@ -1,320 +1,296 @@ -/** -* @ngdoc overview -* @name ui.bootstrap.carousel -* -* @description -* AngularJS version of an image carousel. -* -*/ -angular.module('ui.bootstrap.carousel', ['ui.bootstrap.transition']) -.controller('CarouselController', ['$scope', '$timeout', '$transition', '$q', function ($scope, $timeout, $transition, $q) { +angular.module('ui.bootstrap.carousel', []) + +.controller('UibCarouselController', ['$scope', '$element', '$interval', '$timeout', '$animate', function($scope, $element, $interval, $timeout, $animate) { var self = this, - slides = self.slides = [], - currentIndex = -1, - currentTimeout, isPlaying; - self.currentSlide = null; + slides = self.slides = $scope.slides = [], + SLIDE_DIRECTION = 'uib-slideDirection', + currentIndex = $scope.active, + currentInterval, isPlaying; - /* direction: "prev" or "next" */ - self.select = function(nextSlide, direction) { - var nextIndex = slides.indexOf(nextSlide); - //Decide direction if it's not given - if (direction === undefined) { - direction = nextIndex > currentIndex ? "next" : "prev"; - } - if (nextSlide && nextSlide !== self.currentSlide) { + var destroyed = false; + $element.addClass('carousel'); + + self.addSlide = function(slide, element) { + slides.push({ + slide: slide, + element: element + }); + slides.sort(function(a, b) { + return +a.slide.index - +b.slide.index; + }); + //if this is the first slide or the slide is set to active, select it + if (slide.index === $scope.active || slides.length === 1 && !angular.isNumber($scope.active)) { if ($scope.$currentTransition) { - $scope.$currentTransition.cancel(); - //Timeout so ng-class in template has time to fix classes for finished slide - $timeout(goNext); - } else { - goNext(); + $scope.$currentTransition = null; + } + + currentIndex = slide.index; + $scope.active = slide.index; + setActive(currentIndex); + self.select(slides[findSlideIndex(slide)]); + if (slides.length === 1) { + $scope.play(); } } - function goNext() { - //If we have a slide to transition from and we have a transition type and we're allowed, go - if (self.currentSlide && angular.isString(direction) && !$scope.noTransition && nextSlide.$element) { - //We shouldn't do class manip in here, but it's the same weird thing bootstrap does. need to fix sometime - nextSlide.$element.addClass(direction); - nextSlide.$element[0].offsetWidth = nextSlide.$element[0].offsetWidth; //force reflow - - //Set all other slides to stop doing their stuff for the new transition - angular.forEach(slides, function(slide) { - angular.extend(slide, {direction: '', entering: false, leaving: false, active: false}); - }); - angular.extend(nextSlide, {direction: direction, active: true, entering: true}); - angular.extend(self.currentSlide||{}, {direction: direction, leaving: true}); - - $scope.$currentTransition = $transition(nextSlide.$element, {}); - //We have to create new pointers inside a closure since next & current will change - (function(next,current) { - $scope.$currentTransition.then( - function(){ transitionDone(next, current); }, - function(){ transitionDone(next, current); } - ); - }(nextSlide, self.currentSlide)); - } else { - transitionDone(nextSlide, self.currentSlide); + }; + + self.getCurrentIndex = function() { + for (var i = 0; i < slides.length; i++) { + if (slides[i].slide.index === currentIndex) { + return i; } - self.currentSlide = nextSlide; - currentIndex = nextIndex; - //every time you change slides, reset the timer - restartTimer(); } - function transitionDone(next, current) { - angular.extend(next, {direction: '', active: true, leaving: false, entering: false}); - angular.extend(current||{}, {direction: '', active: false, leaving: false, entering: false}); - $scope.$currentTransition = null; + }; + + self.next = $scope.next = function() { + var newIndex = (self.getCurrentIndex() + 1) % slides.length; + + if (newIndex === 0 && $scope.noWrap()) { + $scope.pause(); + return; } + + return self.select(slides[newIndex], 'next'); }; - /* Allow outside people to call indexOf on slides array */ - self.indexOfSlide = function(slide) { - return slides.indexOf(slide); + self.prev = $scope.prev = function() { + var newIndex = self.getCurrentIndex() - 1 < 0 ? slides.length - 1 : self.getCurrentIndex() - 1; + + if ($scope.noWrap() && newIndex === slides.length - 1) { + $scope.pause(); + return; + } + + return self.select(slides[newIndex], 'prev'); }; - $scope.next = function() { - var newIndex = (currentIndex + 1) % slides.length; - - //Prevent this user-triggered transition from occurring if there is already one in progress - if (!$scope.$currentTransition) { - return self.select(slides[newIndex], 'next'); + self.removeSlide = function(slide) { + var index = findSlideIndex(slide); + + //get the index of the slide inside the carousel + slides.splice(index, 1); + if (slides.length > 0 && currentIndex === index) { + if (index >= slides.length) { + currentIndex = slides.length - 1; + $scope.active = currentIndex; + setActive(currentIndex); + self.select(slides[slides.length - 1]); + } else { + currentIndex = index; + $scope.active = currentIndex; + setActive(currentIndex); + self.select(slides[index]); + } + } else if (currentIndex > index) { + currentIndex--; + $scope.active = currentIndex; + } + + //clean the active value when no more slide + if (slides.length === 0) { + currentIndex = null; + $scope.active = null; } }; - $scope.prev = function() { - var newIndex = currentIndex - 1 < 0 ? slides.length - 1 : currentIndex - 1; - + /* direction: "prev" or "next" */ + self.select = $scope.select = function(nextSlide, direction) { + var nextIndex = findSlideIndex(nextSlide.slide); + //Decide direction if it's not given + if (direction === undefined) { + direction = nextIndex > self.getCurrentIndex() ? 'next' : 'prev'; + } //Prevent this user-triggered transition from occurring if there is already one in progress - if (!$scope.$currentTransition) { - return self.select(slides[newIndex], 'prev'); + if (nextSlide.slide.index !== currentIndex && + !$scope.$currentTransition) { + goNext(nextSlide.slide, nextIndex, direction); } }; - $scope.select = function(slide) { - self.select(slide); + /* Allow outside people to call indexOf on slides array */ + $scope.indexOfSlide = function(slide) { + return +slide.slide.index; }; $scope.isActive = function(slide) { - return self.currentSlide === slide; + return $scope.active === slide.slide.index; }; - $scope.slides = function() { - return slides; + $scope.isPrevDisabled = function() { + return $scope.active === 0 && $scope.noWrap(); }; - $scope.$watch('interval', restartTimer); - function restartTimer() { - if (currentTimeout) { - $timeout.cancel(currentTimeout); - } - function go() { - if (isPlaying) { - $scope.next(); - restartTimer(); - } else { - $scope.pause(); - } - } - var interval = +$scope.interval; - if (!isNaN(interval) && interval>=0) { - currentTimeout = $timeout(go, interval); + $scope.isNextDisabled = function() { + return $scope.active === slides.length - 1 && $scope.noWrap(); + }; + + $scope.pause = function() { + if (!$scope.noPause) { + isPlaying = false; + resetTimer(); } - } + }; + $scope.play = function() { if (!isPlaying) { isPlaying = true; restartTimer(); } }; - $scope.pause = function() { - if (!$scope.noPause) { - isPlaying = false; - if (currentTimeout) { - $timeout.cancel(currentTimeout); + + $element.on('mouseenter', $scope.pause); + $element.on('mouseleave', $scope.play); + + $scope.$on('$destroy', function() { + destroyed = true; + resetTimer(); + }); + + $scope.$watch('noTransition', function(noTransition) { + $animate.enabled($element, !noTransition); + }); + + $scope.$watch('interval', restartTimer); + + $scope.$watchCollection('slides', resetTransition); + + $scope.$watch('active', function(index) { + if (angular.isNumber(index) && currentIndex !== index) { + for (var i = 0; i < slides.length; i++) { + if (slides[i].slide.index === index) { + index = i; + break; + } + } + + var slide = slides[index]; + if (slide) { + setActive(index); + self.select(slides[index]); + currentIndex = index; } } - }; + }); - self.addSlide = function(slide, element) { - slide.$element = element; - slides.push(slide); - //if this is the first slide or the slide is set to active, select it - if(slides.length === 1 || slide.active) { - self.select(slides[slides.length-1]); - if (slides.length == 1) { - $scope.play(); + function getSlideByIndex(index) { + for (var i = 0, l = slides.length; i < l; ++i) { + if (slides[i].index === index) { + return slides[i]; } - } else { - slide.active = false; } - }; + } - self.removeSlide = function(slide) { - //get the index of the slide inside the carousel - var index = slides.indexOf(slide); - slides.splice(index, 1); - if (slides.length > 0 && slide.active) { - if (index >= slides.length) { - self.select(slides[index-1]); - } else { - self.select(slides[index]); + function setActive(index) { + for (var i = 0; i < slides.length; i++) { + slides[i].slide.active = i === index; + } + } + + function goNext(slide, index, direction) { + if (destroyed) { + return; + } + + angular.extend(slide, {direction: direction}); + angular.extend(slides[currentIndex].slide || {}, {direction: direction}); + if ($animate.enabled($element) && !$scope.$currentTransition && + slides[index].element && self.slides.length > 1) { + slides[index].element.data(SLIDE_DIRECTION, slide.direction); + var currentIdx = self.getCurrentIndex(); + + if (angular.isNumber(currentIdx) && slides[currentIdx].element) { + slides[currentIdx].element.data(SLIDE_DIRECTION, slide.direction); } - } else if (currentIndex > index) { - currentIndex--; + + $scope.$currentTransition = true; + $animate.on('addClass', slides[index].element, function(element, phase) { + if (phase === 'close') { + $scope.$currentTransition = null; + $animate.off('addClass', element); + } + }); } - }; -}]) -/** - * @ngdoc directive - * @name ui.bootstrap.carousel.directive:carousel - * @restrict EA - * - * @description - * Carousel is the outer container for a set of image 'slides' to showcase. - * - * @param {number=} interval The time, in milliseconds, that it will take the carousel to go to the next slide. - * @param {boolean=} noTransition Whether to disable transitions on the carousel. - * @param {boolean=} noPause Whether to disable pausing on the carousel (by default, the carousel interval pauses on hover). - * - * @example - - - - - - - Beautiful! - - - - - - D'aww! - - - - - - .carousel-indicators { - top: auto; - bottom: 15px; + $scope.active = slide.index; + currentIndex = slide.index; + setActive(index); + + //every time you change slides, reset the timer + restartTimer(); + } + + function findSlideIndex(slide) { + for (var i = 0; i < slides.length; i++) { + if (slides[i].slide === slide) { + return i; + } + } + } + + function resetTimer() { + if (currentInterval) { + $interval.cancel(currentInterval); + currentInterval = null; + } + } + + function resetTransition(slides) { + if (!slides.length) { + $scope.$currentTransition = null; + } + } + + function restartTimer() { + resetTimer(); + var interval = +$scope.interval; + if (!isNaN(interval) && interval > 0) { + currentInterval = $interval(timerFn, interval); + } + } + + function timerFn() { + var interval = +$scope.interval; + if (isPlaying && !isNaN(interval) && interval > 0 && slides.length) { + $scope.next(); + } else { + $scope.pause(); } - - - */ -.directive('carousel', [function() { + } +}]) + +.directive('uibCarousel', function() { return { - restrict: 'EA', transclude: true, - replace: true, - controller: 'CarouselController', - require: 'carousel', - templateUrl: 'template/carousel/carousel.html', + controller: 'UibCarouselController', + controllerAs: 'carousel', + restrict: 'A', + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/carousel/carousel.html'; + }, scope: { + active: '=', interval: '=', noTransition: '=', - noPause: '=' + noPause: '=', + noWrap: '&' } }; -}]) +}) -/** - * @ngdoc directive - * @name ui.bootstrap.carousel.directive:slide - * @restrict EA - * - * @description - * Creates a slide inside a {@link ui.bootstrap.carousel.directive:carousel carousel}. Must be placed as a child of a carousel element. - * - * @param {boolean=} active Model binding, whether or not this slide is currently active. - * - * @example - - - - - - - - Slide {{$index}} - {{slide.text}} - - - - - - - - select - {{$index}}: {{slide.text}} - - - Add Slide - - - Interval, in milliseconds: - Enter a negative number to stop the interval. - - - - - -function CarouselDemoCtrl($scope) { - $scope.myInterval = 5000; - var slides = $scope.slides = []; - $scope.addSlide = function() { - var newWidth = 200 + ((slides.length + (25 * slides.length)) % 150); - slides.push({ - image: 'http://placekitten.com/' + newWidth + '/200', - text: ['More','Extra','Lots of','Surplus'][slides.length % 4] + ' ' - ['Cats', 'Kittys', 'Felines', 'Cutes'][slides.length % 4] - }); - }; - for (var i=0; i<4; i++) $scope.addSlide(); -} - - - .carousel-indicators { - top: auto; - bottom: 15px; - } - - -*/ - -.directive('slide', ['$parse', function($parse) { +.directive('uibSlide', ['$animate', function($animate) { return { - require: '^carousel', - restrict: 'EA', + require: '^uibCarousel', + restrict: 'A', transclude: true, - replace: true, - templateUrl: 'template/carousel/slide.html', + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/carousel/slide.html'; + }, scope: { + actual: '=?', + index: '=?' }, link: function (scope, element, attrs, carouselCtrl) { - //Set up optional 'active' = binding - if (attrs.active) { - var getActive = $parse(attrs.active); - var setActive = getActive.assign; - var lastValue = scope.active = getActive(scope.$parent); - scope.$watch(function parentActiveWatch() { - var parentActive = getActive(scope.$parent); - - if (parentActive !== scope.active) { - // we are out of sync and need to copy - if (parentActive !== lastValue) { - // parent changed and it has precedence - lastValue = scope.active = parentActive; - } else { - // if the parent can be assigned then do so - setActive(scope.$parent, parentActive = lastValue = scope.active); - } - } - return parentActive; - }); - } - + element.addClass('item'); carouselCtrl.addSlide(scope, element); //when the scope is destroyed then remove the slide from the current slides array scope.$on('$destroy', function() { @@ -322,10 +298,59 @@ function CarouselDemoCtrl($scope) { }); scope.$watch('active', function(active) { - if (active) { - carouselCtrl.select(scope); - } + $animate[active ? 'addClass' : 'removeClass'](element, 'active'); }); } }; +}]) + +.animation('.item', ['$animateCss', +function($animateCss) { + var SLIDE_DIRECTION = 'uib-slideDirection'; + + function removeClass(element, className, callback) { + element.removeClass(className); + if (callback) { + callback(); + } + } + + return { + beforeAddClass: function(element, className, done) { + if (className === 'active') { + var stopped = false; + var direction = element.data(SLIDE_DIRECTION); + var directionClass = direction === 'next' ? 'left' : 'right'; + var removeClassFn = removeClass.bind(this, element, + directionClass + ' ' + direction, done); + element.addClass(direction); + + $animateCss(element, {addClass: directionClass}) + .start() + .done(removeClassFn); + + return function() { + stopped = true; + }; + } + done(); + }, + beforeRemoveClass: function (element, className, done) { + if (className === 'active') { + var stopped = false; + var direction = element.data(SLIDE_DIRECTION); + var directionClass = direction === 'next' ? 'left' : 'right'; + var removeClassFn = removeClass.bind(this, element, directionClass, done); + + $animateCss(element, {addClass: directionClass}) + .start() + .done(removeClassFn); + + return function() { + stopped = true; + }; + } + done(); + } + }; }]); diff --git a/src/carousel/docs/README.md b/src/carousel/docs/README.md index 1f9d880cff..80a883a888 100644 --- a/src/carousel/docs/README.md +++ b/src/carousel/docs/README.md @@ -1,3 +1,57 @@ Carousel creates a carousel similar to bootstrap's image carousel. -Use a `` element with `` elements inside it. It will automatically cycle through the slides at a given rate, and a current-index variable will be kept in sync with the currently visible slide. +The carousel also offers support for touchscreen devices in the form of swiping. To enable swiping, load the `ngTouch` module as a dependency. + +Use a `` element with `` elements inside it. + +### uib-carousel settings + +* `active` + + _(Default: `Index of first slide`)_ - + Index of current active slide. + +* `interval` + $ + + _(Default: `none`)_ - + Sets an interval to cycle through the slides. You need a number bigger than 0 to make the interval work. + +* `no-pause` + $ + + _(Default: `false`)_ - + The interval pauses on mouseover. Setting this to truthy, disables this pause. + +* `no-transition` + $ + + _(Default: `false`)_ - + Whether to disable the transition animation between slides. Setting this to truthy, disables this transition. + +* `no-wrap` + $ + _(Default: `false`)_ - + Disables the looping of slides. Setting `no-wrap` to an expression which evaluates to a truthy value will prevent looping. + +* `template-url` + _(Default: `uib/template/carousel/carousel.html`)_ - + Add the ability to override the template used on the component. + +### uib-slide settings + +* `actual` + $ + + _(Default: `none`)_ - + Use this attribute to bind the slide model (or any object of interest) onto the slide scope, which makes it available for customization in the carousel template. + +* `index` + $ + + _(Default: `none`)_ - + The index of the slide. Must be unique. + +* `template-url` + _(Default: `uib/template/carousel/slide.html`)_ - + Add the ability to override the template used on the component. diff --git a/src/carousel/docs/demo.html b/src/carousel/docs/demo.html index 954ef9dd4f..f2a6f48672 100644 --- a/src/carousel/docs/demo.html +++ b/src/carousel/docs/demo.html @@ -1,26 +1,29 @@ - - - - - Slide {{$index}} - {{slide.text}} + + + + + + Slide {{slide.id}} + {{slide.text}} + - - - - - - - select - {{$index}}: {{slide.text}} - - - Add Slide - - Interval, in milliseconds: - Enter a negative number to stop the interval. + + + + Add Slide + Randomize slides + + + + Disable Slide Looping + + + + + Interval, in milliseconds: + Enter a negative number or 0 to stop the interval. diff --git a/src/carousel/docs/demo.js b/src/carousel/docs/demo.js index 7c1f8fa124..79dd322dc6 100644 --- a/src/carousel/docs/demo.js +++ b/src/carousel/docs/demo.js @@ -1,15 +1,57 @@ -function CarouselDemoCtrl($scope) { +angular.module('ui.bootstrap.demo').controller('CarouselDemoCtrl', function ($scope) { $scope.myInterval = 5000; + $scope.noWrapSlides = false; + $scope.active = 0; var slides = $scope.slides = []; + var currIndex = 0; + $scope.addSlide = function() { - var newWidth = 200 + ((slides.length + (25 * slides.length)) % 150); + var newWidth = 600 + slides.length + 1; slides.push({ - image: 'http://placekitten.com/' + newWidth + '/200', - text: ['More','Extra','Lots of','Surplus'][slides.length % 4] + ' ' + - ['Cats', 'Kittys', 'Felines', 'Cutes'][slides.length % 4] + image: '//unsplash.it/' + newWidth + '/300', + text: ['Nice image','Awesome photograph','That is so cool','I love that'][slides.length % 4], + id: currIndex++ }); }; - for (var i=0; i<4; i++) { + + $scope.randomize = function() { + var indexes = generateIndexesArray(); + assignNewIndexesToSlides(indexes); + }; + + for (var i = 0; i < 4; i++) { $scope.addSlide(); } -} + + // Randomize logic below + + function assignNewIndexesToSlides(indexes) { + for (var i = 0, l = slides.length; i < l; i++) { + slides[i].id = indexes.pop(); + } + } + + function generateIndexesArray() { + var indexes = []; + for (var i = 0; i < currIndex; ++i) { + indexes[i] = i; + } + return shuffle(indexes); + } + + // http://stackoverflow.com/questions/962802#962890 + function shuffle(array) { + var tmp, current, top = array.length; + + if (top) { + while (--top) { + current = Math.floor(Math.random() * (top + 1)); + tmp = array[current]; + array[current] = array[top]; + array[top] = tmp; + } + } + + return array; + } +}); diff --git a/src/carousel/index-nocss.js b/src/carousel/index-nocss.js new file mode 100644 index 0000000000..4a383f07db --- /dev/null +++ b/src/carousel/index-nocss.js @@ -0,0 +1,9 @@ +require('../../template/carousel/carousel.html.js'); +require('../../template/carousel/slide.html.js'); +require('./carousel'); + +var MODULE_NAME = 'ui.bootstrap.module.carousel'; + +angular.module(MODULE_NAME, ['ui.bootstrap.carousel', 'uib/template/carousel/carousel.html', 'uib/template/carousel/slide.html']); + +module.exports = MODULE_NAME; diff --git a/src/carousel/index.js b/src/carousel/index.js new file mode 100644 index 0000000000..e55e95dc36 --- /dev/null +++ b/src/carousel/index.js @@ -0,0 +1,2 @@ +require('./carousel.css'); +module.exports = require('./index-nocss.js'); diff --git a/src/carousel/test/carousel.spec.js b/src/carousel/test/carousel.spec.js index 0bea1d3daa..87b586dbd4 100644 --- a/src/carousel/test/carousel.spec.js +++ b/src/carousel/test/carousel.spec.js @@ -1,54 +1,76 @@ describe('carousel', function() { beforeEach(module('ui.bootstrap.carousel')); - beforeEach(module('template/carousel/carousel.html', 'template/carousel/slide.html')); - - var $rootScope, elm, $compile, $controller, $timeout; - beforeEach(inject(function(_$rootScope_, _$compile_, _$controller_, _$timeout_) { + beforeEach(module('ngAnimateMock')); + beforeEach(module('uib/template/carousel/carousel.html', 'uib/template/carousel/slide.html')); + + var $rootScope, $compile, $controller, $interval, $templateCache, $timeout, $animate; + beforeEach(inject(function(_$rootScope_, _$compile_, _$controller_, _$interval_, _$templateCache_, _$timeout_, _$animate_) { $rootScope = _$rootScope_; $compile = _$compile_; $controller = _$controller_; + $interval = _$interval_; + $templateCache = _$templateCache_; $timeout = _$timeout_; + $animate = _$animate_; })); describe('basics', function() { - var elm, scope, carouselScope; + var elm, scope; beforeEach(function() { scope = $rootScope.$new(); scope.slides = [ - {active:false,content:'one'}, - {active:false,content:'two'}, - {active:false,content:'three'} + {content: 'one', index: 0}, + {content: 'two', index: 1}, + {content: 'three', index: 2} ]; elm = $compile( - '' + - '' + + '' + + '' + '{{slide.content}}' + - '' + - '' + '' + + '' )(scope); - carouselScope = elm.scope(); scope.interval = 5000; scope.nopause = undefined; scope.$apply(); }); - afterEach(function() { - scope.$destroy(); - }); function testSlideActive(slideIndex) { - for (var i=0; ifoo'); + + elm = $compile('')(scope); + $rootScope.$digest(); + + expect(elm.html()).toBe('foo'); + }); + + it('should allow overriding of the slide template', function() { + $templateCache.put('foo/bar.html', 'bar'); + + elm = $compile( + '' + + '' + + '' + )(scope); + $rootScope.$digest(); + + var slide = elm.find('.slide'); + expect(slide.html()).toBe('bar'); + }); + + it('should be able to select a slide via model changes', function() { testSlideActive(0); - scope.$apply('slides[1].active=true'); + scope.$apply('active=1'); testSlideActive(1); }); @@ -64,34 +86,110 @@ describe('carousel', function() { var indicators = elm.find('ol.carousel-indicators > li'); expect(indicators.length).toBe(3); }); - + + it('should stop cycling slides forward when noWrap is truthy', function () { + elm = $compile( + '' + + '' + + '{{slide.content}}' + + '' + + '' + )(scope); + + scope.noWrap = true; + scope.$apply(); + + var $scope = elm.isolateScope(); + spyOn($scope, 'pause'); + + scope.active = $scope.slides.length - 1; + scope.$apply(); + testSlideActive($scope.slides.length - 1); + $scope.next(); + testSlideActive($scope.slides.length - 1); + expect($scope.pause).toHaveBeenCalled(); + }); + + it('should stop cycling slides backward when noWrap is truthy', function () { + elm = $compile( + '' + + '' + + '{{slide.content}}' + + '' + + '' + )(scope); + + scope.noWrap = true; + scope.$apply(); + + var $scope = elm.isolateScope(); + spyOn($scope, 'pause'); + + testSlideActive(0); + $scope.prev(); + testSlideActive(0); + expect($scope.pause).toHaveBeenCalled(); + }); + it('should hide navigation when only one slide', function () { - scope.slides=[{active:false,content:'one'}]; + scope.slides = [{active:false,content:'one'}]; scope.$apply(); elm = $compile( - '' + - '' + - '{{slide.content}}' + - '' + - '' - )(scope); + '' + + '' + + '{{slide.content}}' + + '' + + '' + )(scope); var indicators = elm.find('ol.carousel-indicators > li'); expect(indicators.length).toBe(0); - + var navNext = elm.find('a.right'); expect(navNext.length).toBe(0); - + var navPrev = elm.find('a.left'); expect(navPrev.length).toBe(0); }); - - it('should show navigation when there are 3 slides', function () { + + it('should disable prev button when slide index is 0 and noWrap is truthy', function() { + scope.$apply(); + + var $scope = elm.isolateScope(); + $scope.noWrap = function() {return true;}; + + $scope.isPrevDisabled(); + scope.$apply(); + + var navPrev = elm.find('a.left'); + expect(navPrev.hasClass('disabled')).toBe(true); + }); + + it('should disable next button when last slide is active and noWrap is truthy', function() { + scope.slides = [ + {content: 'one', index: 0}, + {content: 'two', index: 1} + ]; + + scope.$apply(); + + var $scope = elm.isolateScope(); + $scope.noWrap = function() {return true;}; + $scope.next(); + + $scope.isNextDisabled(); + scope.$apply(); + + var navNext = elm.find('a.right'); + expect(navNext.hasClass('disabled')).toBe(true); + }); + + it('should show navigation when there are 3 slides', function () { var indicators = elm.find('ol.carousel-indicators > li'); expect(indicators.length).not.toBe(0); - + var navNext = elm.find('a.right'); expect(navNext.length).not.toBe(0); - + var navPrev = elm.find('a.left'); expect(navPrev.length).not.toBe(0); }); @@ -124,23 +222,28 @@ describe('carousel', function() { testSlideActive(1); }); - it('shouldnt go forward if interval is NaN or negative', function() { + it('shouldnt go forward if interval is NaN or negative or has no slides', function() { testSlideActive(0); + var previousInterval = scope.interval; scope.$apply('interval = -1'); - //no timeout to flush, interval watch doesn't make a new one when interval is invalid + $interval.flush(previousInterval); testSlideActive(0); scope.$apply('interval = 1000'); - $timeout.flush(); + $interval.flush(1000); testSlideActive(1); scope.$apply('interval = false'); + $interval.flush(1000); testSlideActive(1); scope.$apply('interval = 1000'); - $timeout.flush(); + $interval.flush(1000); + testSlideActive(2); + scope.$apply('slides = []'); + $interval.flush(1000); testSlideActive(2); }); it('should bind the content to slides', function() { - var contents = elm.find('div.item'); + var contents = elm.find('div.item [ng-transclude]'); expect(contents.length).toBe(3); expect(contents.eq(0).text()).toBe('one'); @@ -160,64 +263,65 @@ describe('carousel', function() { it('should be playing by default and cycle through slides', function() { testSlideActive(0); - $timeout.flush(); + $interval.flush(scope.interval); testSlideActive(1); - $timeout.flush(); + $interval.flush(scope.interval); testSlideActive(2); - $timeout.flush(); + $interval.flush(scope.interval); testSlideActive(0); }); it('should pause and play on mouseover', function() { testSlideActive(0); - $timeout.flush(); + $interval.flush(scope.interval); testSlideActive(1); elm.trigger('mouseenter'); - expect($timeout.flush).toThrow();//pause should cancel current timeout + testSlideActive(1); + $interval.flush(scope.interval); testSlideActive(1); elm.trigger('mouseleave'); - $timeout.flush(); + $interval.flush(scope.interval); testSlideActive(2); }); - + it('should not pause on mouseover if noPause', function() { scope.$apply('nopause = true'); testSlideActive(0); elm.trigger('mouseenter'); - $timeout.flush(); + $interval.flush(scope.interval); testSlideActive(1); elm.trigger('mouseleave'); - $timeout.flush(); + $interval.flush(scope.interval); testSlideActive(2); - }); + }); it('should remove slide from dom and change active slide', function() { - scope.$apply('slides[2].active = true'); + scope.$apply('active = 2'); testSlideActive(2); - scope.$apply('slides.splice(0,1)'); + scope.$apply('slides.splice(2,1)'); + $timeout.flush(0); expect(elm.find('div.item').length).toBe(2); testSlideActive(1); - $timeout.flush(); + $interval.flush(scope.interval); testSlideActive(0); scope.$apply('slides.splice(1,1)'); + $timeout.flush(0); expect(elm.find('div.item').length).toBe(1); testSlideActive(0); }); it('should change dom when you reassign ng-repeat slides array', function() { - scope.slides=[{content:'new1'},{content:'new2'},{content:'new3'}]; + scope.slides = [ + {content:'new1', index: 4}, + {content:'new2', index: 5}, + {content:'new3', index: 6} + ]; scope.$apply(); - var contents = elm.find('div.item'); + var contents = elm.find('div.item [ng-transclude]'); expect(contents.length).toBe(3); expect(contents.eq(0).text()).toBe('new1'); expect(contents.eq(1).text()).toBe('new2'); expect(contents.eq(2).text()).toBe('new3'); - scope.$apply('slides.splice(0,1)'); - contents = elm.find('div.item'); - expect(contents.length).toBe(2); - expect(contents.eq(0).text()).toBe('new2'); - expect(contents.eq(0)).toHaveClass('active'); - expect(contents.eq(1).text()).toBe('new3'); }); it('should not change if next is clicked while transitioning', function() { @@ -234,66 +338,259 @@ describe('carousel', function() { next.click(); testSlideActive(1); }); + + it('should buffer the slides if transition is clicked and only transition to the last requested', function() { + var carouselScope = elm.children().scope(); + + testSlideActive(0); + carouselScope.$currentTransition = null; + carouselScope.select(carouselScope.slides[1]); + $animate.flush(); + + testSlideActive(1); + + carouselScope.$currentTransition = true; + carouselScope.select(carouselScope.slides[2]); + scope.$apply(); + + testSlideActive(1); + + carouselScope.select(carouselScope.slides[0]); + scope.$apply(); + + testSlideActive(1); + + carouselScope.$currentTransition = null; + $interval.flush(scope.interval); + $animate.flush(); + + testSlideActive(2); + + $interval.flush(scope.interval); + $animate.flush(); + + testSlideActive(0); + }); + + it('issue 1414 - should not continue running timers after scope is destroyed', function() { + testSlideActive(0); + $interval.flush(scope.interval); + testSlideActive(1); + $interval.flush(scope.interval); + testSlideActive(2); + $interval.flush(scope.interval); + testSlideActive(0); + spyOn($interval, 'cancel').and.callThrough(); + scope.$destroy(); + expect($interval.cancel).toHaveBeenCalled(); + }); + + it('issue 4390 - should reset the currentTransition if there are no slides', function() { + var carouselScope = elm.children().scope(); + var next = elm.find('a.right'); + scope.slides = [ + {content:'new1', index: 1}, + {content:'new2', index: 2}, + {content:'new3', index: 3} + ]; + scope.$apply(); + + testSlideActive(0); + carouselScope.$currentTransition = true; + + scope.slides = []; + scope.$apply(); + + expect(carouselScope.$currentTransition).toBe(null); + }); + }); + + describe('slide order', function() { + var elm, scope; + beforeEach(function() { + scope = $rootScope.$new(); + scope.slides = [ + {content: 'one', id: 3}, + {content: 'two', id: 1}, + {content: 'three', id: 2} + ]; + elm = $compile( + '' + + '' + + '{{slide.content}}' + + '' + + '' + )(scope); + scope.$apply(); + }); + + function testSlideActive(slideIndex) { + for (var i = 0; i < scope.slides.length; i++) { + if (i === slideIndex) { + expect(scope.active).toBe(scope.slides[i].id); + } else { + expect(scope.active).not.toBe(scope.slides[i].id); + } + } + } + + it('should change dom when the order of the slides changes', function() { + scope.slides[0].id = 3; + scope.slides[1].id = 2; + scope.slides[2].id = 1; + scope.$apply(); + var contents = elm.find('div.item [ng-transclude]'); + expect(contents.length).toBe(3); + expect(contents.eq(0).text()).toBe('three'); + expect(contents.eq(1).text()).toBe('two'); + expect(contents.eq(2).text()).toBe('one'); + }); + + it('should select next after order change', function() { + testSlideActive(1); + var next = elm.find('a.right'); + next.click(); + testSlideActive(2); + }); + + it('should select prev after order change', function() { + testSlideActive(1); + var prev = elm.find('a.left'); + prev.click(); + testSlideActive(0); + }); + + it('should add slide in the specified position', function() { + testSlideActive(1); + scope.slides[2].id = 4; + scope.slides.push({content:'four', id: 5}); + scope.$apply(); + var contents = elm.find('div.item [ng-transclude]'); + expect(contents.length).toBe(4); + expect(contents.eq(0).text()).toBe('two'); + expect(contents.eq(1).text()).toBe('one'); + expect(contents.eq(2).text()).toBe('three'); + expect(contents.eq(3).text()).toBe('four'); + }); + + it('should remove slide after order change', function() { + testSlideActive(1); + scope.slides.splice(1, 1); + scope.$apply(); + var contents = elm.find('div.item [ng-transclude]'); + expect(contents.length).toBe(2); + expect(contents.eq(0).text()).toBe('three'); + expect(contents.eq(1).text()).toBe('one'); + }); }); describe('controller', function() { var scope, ctrl; //create an array of slides and add to the scope - var slides = [{'content': 1},{'content': 2},{'content':3},{'content':4}]; + var slides = [ + {'content': 1, index: 0}, + {'content': 2, index: 1}, + {'content': 3, index: 2}, + {'content': 4, index: 3} + ]; beforeEach(function() { scope = $rootScope.$new(); - ctrl = $controller('CarouselController', {$scope: scope, $element: null}); - for(var i = 0;i < slides.length;i++){ + scope.noWrap = angular.noop; + ctrl = $controller('UibCarouselController', {$scope: scope, $element: angular.element('')}); + for (var i = 0; i < slides.length; i++) { ctrl.addSlide(slides[i]); } }); - afterEach(function() { - scope.$destroy(); - }); - describe('addSlide', function() { - it('should set first slide to active = true and the rest to false', function() { - angular.forEach(ctrl.slides, function(slide, i) { - if (i !== 0) { - expect(slide.active).not.toBe(true); - } else { - expect(slide.active).toBe(true); - } - }); + it('should set first slide to active = true and the rest to false', function() { + angular.forEach(ctrl.slides, function(slide, i) { + if (i !== 0) { + expect(slide.slide.active).not.toBe(true); + } else { + expect(slide.slide.active).toBe(true); + } }); + }); - it('should add new slide and change active to true if active is true on the added slide', function() { - var newSlide = {active: true}; - expect(ctrl.slides.length).toBe(4); - ctrl.addSlide(newSlide); - expect(ctrl.slides.length).toBe(5); - expect(ctrl.slides[4].active).toBe(true); - expect(ctrl.slides[0].active).toBe(false); - }); + it('should add a new slide and not change the active slide', function() { + var newSlide = {active: false, index: 4}; + expect(ctrl.slides.length).toBe(4); + ctrl.addSlide(newSlide); + expect(ctrl.slides.length).toBe(5); + expect(ctrl.slides[4].slide.active).toBe(false); + expect(ctrl.slides[0].slide.active).toBe(true); + }); - it('should add a new slide and not change the active slide', function() { - var newSlide = {active: false}; - expect(ctrl.slides.length).toBe(4); - ctrl.addSlide(newSlide); - expect(ctrl.slides.length).toBe(5); - expect(ctrl.slides[4].active).toBe(false); - expect(ctrl.slides[0].active).toBe(true); - }); + it('should remove slide and change active slide if needed', function() { + expect(ctrl.slides.length).toBe(4); + ctrl.removeSlide(ctrl.slides[0].slide); + $timeout.flush(0); + expect(ctrl.slides.length).toBe(3); + expect(scope.active).toBe(1); + ctrl.select(ctrl.slides[2]); + ctrl.removeSlide(ctrl.slides[2].slide); + $timeout.flush(0); + expect(ctrl.slides.length).toBe(2); + expect(scope.active).toBe(2); + ctrl.removeSlide(ctrl.slides[0].slide); + $timeout.flush(0); + expect(ctrl.slides.length).toBe(1); + expect(scope.active).toBe(1); + }); - it('should remove slide and change active slide if needed', function() { - expect(ctrl.slides.length).toBe(4); - ctrl.removeSlide(ctrl.slides[0]); - expect(ctrl.slides.length).toBe(3); - expect(ctrl.currentSlide).toBe(ctrl.slides[0]); - ctrl.select(ctrl.slides[2]); - ctrl.removeSlide(ctrl.slides[2]); - expect(ctrl.slides.length).toBe(2); - expect(ctrl.currentSlide).toBe(ctrl.slides[1]); - ctrl.removeSlide(ctrl.slides[0]); - expect(ctrl.slides.length).toBe(1); - expect(ctrl.currentSlide).toBe(ctrl.slides[0]); - }); + it('issue 1414 - should not continue running timers after scope is destroyed', function() { + spyOn(scope, 'next'); + scope.interval = 2000; + scope.$digest(); + + $interval.flush(scope.interval); + expect(scope.next.calls.count()).toBe(1); + + scope.$destroy(); + + $interval.flush(scope.interval); + expect(scope.next.calls.count()).toBe(1); }); + + it('should be exposed in the template', inject(function($templateCache) { + $templateCache.put('uib/template/carousel/carousel.html', '{{carousel.text}}'); + + var scope = $rootScope.$new(); + var elm = $compile('')(scope); + $rootScope.$digest(); + + var ctrl = elm.controller('uibCarousel'); + + expect(ctrl).toBeDefined(); + + ctrl.text = 'foo'; + $rootScope.$digest(); + + expect(elm.html()).toBe('foo'); + })); + }); + + it('should expose a custom model in the carousel slide', function() { + var scope = $rootScope.$new(); + scope.slides = [ + {active:false,content:'one'}, + {active:false,content:'two'}, + {active:false,content:'three'} + ]; + var elm = $compile( + '' + + '' + + '{{slide.content}}' + + '' + + '' + )(scope); + $rootScope.$digest(); + + var ctrl = elm.controller('uibCarousel'); + + expect(angular.equals(ctrl.slides.map(function(slide) { + return slide.slide.actual; + }), scope.slides)).toBe(true); }); }); diff --git a/src/collapse/collapse.js b/src/collapse/collapse.js index d0a33b454f..7e605b641d 100644 --- a/src/collapse/collapse.js +++ b/src/collapse/collapse.js @@ -1,95 +1,133 @@ -angular.module('ui.bootstrap.collapse',['ui.bootstrap.transition']) +angular.module('ui.bootstrap.collapse', []) -// The collapsible directive indicates a block of html that will expand and collapse -.directive('collapse', ['$transition', function($transition) { - // CSS transitions don't work with height: auto, so we have to manually change the height to a - // specific value and then once the animation completes, we can reset the height to auto. - // Unfortunately if you do this while the CSS transitions are specified (i.e. in the CSS class - // "collapse") then you trigger a change to height 0 in between. - // The fix is to remove the "collapse" CSS class while changing the height back to auto - phew! - var fixUpHeight = function(scope, element, height) { - // We remove the collapse CSS class to prevent a transition when we change to height: auto - element.removeClass('collapse'); - element.css({ height: height }); - // It appears that reading offsetWidth makes the browser realise that we have changed the - // height already :-/ - var x = element[0].offsetWidth; - element.addClass('collapse'); - }; + .directive('uibCollapse', ['$animate', '$q', '$parse', '$injector', function($animate, $q, $parse, $injector) { + var $animateCss = $injector.has('$animateCss') ? $injector.get('$animateCss') : null; + return { + link: function(scope, element, attrs) { + var expandingExpr = $parse(attrs.expanding), + expandedExpr = $parse(attrs.expanded), + collapsingExpr = $parse(attrs.collapsing), + collapsedExpr = $parse(attrs.collapsed), + horizontal = false, + css = {}, + cssTo = {}; - return { - link: function(scope, element, attrs) { + init(); - var isCollapsed; - var initialAnimSkip = true; - scope.$watch(function (){ return element[0].scrollHeight; }, function (value) { - //The listener is called when scollHeight changes - //It actually does on 2 scenarios: - // 1. Parent is set to display none - // 2. angular bindings inside are resolved - //When we have a change of scrollHeight we are setting again the correct height if the group is opened - if (element[0].scrollHeight !== 0) { - if (!isCollapsed) { - if (initialAnimSkip) { - fixUpHeight(scope, element, element[0].scrollHeight + 'px'); - } else { - fixUpHeight(scope, element, 'auto'); - } + function init() { + horizontal = !!('horizontal' in attrs); + if (horizontal) { + css = { + width: '' + }; + cssTo = {width: '0'}; + } else { + css = { + height: '' + }; + cssTo = {height: '0'}; + } + if (!scope.$eval(attrs.uibCollapse)) { + element.addClass('in') + .addClass('collapse') + .attr('aria-expanded', true) + .attr('aria-hidden', false) + .css(css); } } - }); - - scope.$watch(attrs.collapse, function(value) { - if (value) { - collapse(); - } else { - expand(); + + function getScrollFromElement(element) { + if (horizontal) { + return {width: element.scrollWidth + 'px'}; + } + return {height: element.scrollHeight + 'px'}; } - }); - - var currentTransition; - var doTransition = function(change) { - if ( currentTransition ) { - currentTransition.cancel(); + function expand() { + if (element.hasClass('collapse') && element.hasClass('in')) { + return; + } + + $q.resolve(expandingExpr(scope)) + .then(function() { + element.removeClass('collapse') + .addClass('collapsing') + .attr('aria-expanded', true) + .attr('aria-hidden', false); + + if ($animateCss) { + $animateCss(element, { + addClass: 'in', + easing: 'ease', + css: { + overflow: 'hidden' + }, + to: getScrollFromElement(element[0]) + }).start()['finally'](expandDone); + } else { + $animate.addClass(element, 'in', { + css: { + overflow: 'hidden' + }, + to: getScrollFromElement(element[0]) + }).then(expandDone); + } + }, angular.noop); } - currentTransition = $transition(element,change); - currentTransition.then( - function() { currentTransition = undefined; }, - function() { currentTransition = undefined; } - ); - return currentTransition; - }; - var expand = function() { - if (initialAnimSkip) { - initialAnimSkip = false; - if ( !isCollapsed ) { - fixUpHeight(scope, element, 'auto'); + function expandDone() { + element.removeClass('collapsing') + .addClass('collapse') + .css(css); + expandedExpr(scope); + } + + function collapse() { + if (!element.hasClass('collapse') && !element.hasClass('in')) { + return collapseDone(); } - } else { - doTransition({ height : element[0].scrollHeight + 'px' }) - .then(function() { - // This check ensures that we don't accidentally update the height if the user has closed - // the group while the animation was still running - if ( !isCollapsed ) { - fixUpHeight(scope, element, 'auto'); - } - }); + + $q.resolve(collapsingExpr(scope)) + .then(function() { + element + // IMPORTANT: The width must be set before adding "collapsing" class. + // Otherwise, the browser attempts to animate from width 0 (in + // collapsing class) to the given width here. + .css(getScrollFromElement(element[0])) + // initially all panel collapse have the collapse class, this removal + // prevents the animation from jumping to collapsed state + .removeClass('collapse') + .addClass('collapsing') + .attr('aria-expanded', false) + .attr('aria-hidden', true); + + if ($animateCss) { + $animateCss(element, { + removeClass: 'in', + to: cssTo + }).start()['finally'](collapseDone); + } else { + $animate.removeClass(element, 'in', { + to: cssTo + }).then(collapseDone); + } + }, angular.noop); } - isCollapsed = false; - }; - - var collapse = function() { - isCollapsed = true; - if (initialAnimSkip) { - initialAnimSkip = false; - fixUpHeight(scope, element, 0); - } else { - fixUpHeight(scope, element, element[0].scrollHeight + 'px'); - doTransition({'height':'0'}); + + function collapseDone() { + element.css(cssTo); // Required so that collapse works when animation is disabled + element.removeClass('collapsing') + .addClass('collapse'); + collapsedExpr(scope); } - }; - } - }; -}]); + + scope.$watch(attrs.uibCollapse, function(shouldCollapse) { + if (shouldCollapse) { + collapse(); + } else { + expand(); + } + }); + } + }; + }]); diff --git a/src/collapse/docs/demo.html b/src/collapse/docs/demo.html index 207113ee76..f5e06e7fbc 100644 --- a/src/collapse/docs/demo.html +++ b/src/collapse/docs/demo.html @@ -1,7 +1,40 @@ + - Toggle collapse + Resize window to less than 768 pixels to display mobile menu toggle button. + + + + Toggle navigation + + + + + A menu + + + + Link 1 + Link 2 + + + - - Some content + Toggle collapse Vertically + + + Some content + + + Toggle collapse Horizontally + + + Some content - \ No newline at end of file + diff --git a/src/collapse/docs/demo.js b/src/collapse/docs/demo.js index b85bab4b52..f685290001 100644 --- a/src/collapse/docs/demo.js +++ b/src/collapse/docs/demo.js @@ -1,3 +1,5 @@ -function CollapseDemoCtrl($scope) { +angular.module('ui.bootstrap.demo').controller('CollapseDemoCtrl', function ($scope) { + $scope.isNavCollapsed = true; $scope.isCollapsed = false; -} + $scope.isCollapsedHorizontal = false; +}); diff --git a/src/collapse/docs/readme.md b/src/collapse/docs/readme.md index 64aab480d6..5cdacfd3d0 100644 --- a/src/collapse/docs/readme.md +++ b/src/collapse/docs/readme.md @@ -1,2 +1,37 @@ -AngularJS version of twitter's collapse plugin. -Provides a simple way to hide and show an element with a css transition +**uib-collapse** provides a simple way to hide and show an element with a css transition + +### uib-collapse settings + +* `collapsed()` + $ - + An optional expression called after the element finished collapsing. + +* `collapsing()` + $ - + An optional expression called before the element begins collapsing. + If the expression returns a promise, animation won't start until the promise resolves. + If the returned promise is rejected, collapsing will be cancelled. + +* `expanded()` + $ - + An optional expression called after the element finished expanding. + +* `expanding()` + $ - + An optional expression called before the element begins expanding. + If the expression returns a promise, animation won't start until the promise resolves. + If the returned promise is rejected, expanding will be cancelled. + +* `uib-collapse` + $ + + _(Default: `false`)_ - + Whether the element should be collapsed or not. + +* `horizontal` + $ - + An optional attribute that permit to collapse horizontally. + +### Known Issues + +When using the `horizontal` attribute with this directive, CSS can reflow as the collapse element goes from `0px` to its desired end width, which can result in height changes. This can cause animations to not appear to run. The best way around this is to set a fixed height via CSS on the horizontal collapse element so that this situation does not occur, and so the animation can run as expected. diff --git a/src/collapse/index.js b/src/collapse/index.js new file mode 100644 index 0000000000..b720e842c4 --- /dev/null +++ b/src/collapse/index.js @@ -0,0 +1,7 @@ +require('./collapse'); + +var MODULE_NAME = 'ui.bootstrap.module.collapse'; + +angular.module(MODULE_NAME, ['ui.bootstrap.collapse']); + +module.exports = MODULE_NAME; diff --git a/src/collapse/test/collapse.spec.js b/src/collapse/test/collapse.spec.js index d04a8b3c90..f9b8c4e771 100644 --- a/src/collapse/test/collapse.spec.js +++ b/src/collapse/test/collapse.spec.js @@ -1,19 +1,24 @@ -describe('collapse directive', function () { - - var scope, $compile, $timeout, $transition; +describe('collapse directive', function() { + var element, compileFn, scope, $compile, $animate, $q; beforeEach(module('ui.bootstrap.collapse')); - beforeEach(inject(function(_$rootScope_, _$compile_, _$timeout_, _$transition_) { + beforeEach(module('ngAnimateMock')); + beforeEach(inject(function(_$rootScope_, _$compile_, _$animate_, _$q_) { scope = _$rootScope_; $compile = _$compile_; - $timeout = _$timeout_; - $transition = _$transition_; + $animate = _$animate_; + $q = _$q_; })); - var element; - beforeEach(function() { - element = $compile('Some Content')(scope); + element = angular.element( + '' + + 'Some Content'); + compileFn = $compile(element); angular.element(document.body).append(element); }); @@ -21,43 +26,146 @@ describe('collapse directive', function () { element.remove(); }); - it('should be hidden on initialization if isCollapsed = true without transition', function() { + function initCallbacks() { + scope.collapsing = jasmine.createSpy('scope.collapsing'); + scope.collapsed = jasmine.createSpy('scope.collapsed'); + scope.expanding = jasmine.createSpy('scope.expanding'); + scope.expanded = jasmine.createSpy('scope.expanded'); + } + + function assertCallbacks(expected) { + ['collapsing', 'collapsed', 'expanding', 'expanded'].forEach(function(cbName) { + if (expected[cbName]) { + expect(scope[cbName]).toHaveBeenCalled(); + } else { + expect(scope[cbName]).not.toHaveBeenCalled(); + } + }); + } + + it('should be hidden on initialization if isCollapsed = true', function() { + initCallbacks(); scope.isCollapsed = true; + compileFn(scope); scope.$digest(); - //No animation timeout here expect(element.height()).toBe(0); + assertCallbacks({ collapsed: true }); + }); + + it('should not trigger any animation on initialization if isCollapsed = true', function() { + var wrapperFn = function() { + $animate.flush(); + }; + + scope.isCollapsed = true; + compileFn(scope); + scope.$digest(); + + expect(wrapperFn).toThrowError(/No pending animations ready to be closed or flushed/); }); - it('should collapse if isCollapsed = true with animation on subsequent use', function() { + it('should collapse if isCollapsed = true on subsequent use', function() { scope.isCollapsed = false; + compileFn(scope); scope.$digest(); + initCallbacks(); scope.isCollapsed = true; scope.$digest(); - $timeout.flush(); + $animate.flush(); expect(element.height()).toBe(0); + assertCallbacks({ collapsing: true, collapsed: true }); }); - it('should be shown on initialization if isCollapsed = false without transition', function() { + it('should show after toggled from collapsed', function() { + initCallbacks(); + scope.isCollapsed = true; + compileFn(scope); + scope.$digest(); + expect(element.height()).toBe(0); + assertCallbacks({ collapsed: true }); + scope.collapsed.calls.reset(); + scope.isCollapsed = false; scope.$digest(); - //No animation timeout here + $animate.flush(); expect(element.height()).not.toBe(0); + assertCallbacks({ expanding: true, expanded: true }); }); - it('should expand if isCollapsed = false with animation on subsequent use', function() { + it('should not trigger any animation on initialization if isCollapsed = false', function() { + var wrapperFn = function() { + $animate.flush(); + }; + scope.isCollapsed = false; + compileFn(scope); + scope.$digest(); + + expect(wrapperFn).toThrowError(/No pending animations ready to be closed or flushed/); + }); + + it('should expand if isCollapsed = false on subsequent use', function() { + scope.isCollapsed = false; + compileFn(scope); scope.$digest(); scope.isCollapsed = true; scope.$digest(); + $animate.flush(); + initCallbacks(); scope.isCollapsed = false; scope.$digest(); - $timeout.flush(); + $animate.flush(); expect(element.height()).not.toBe(0); + assertCallbacks({ expanding: true, expanded: true }); + }); + + it('should collapse if isCollapsed = true on subsequent uses', function() { + scope.isCollapsed = false; + compileFn(scope); + scope.$digest(); + scope.isCollapsed = true; + scope.$digest(); + $animate.flush(); + scope.isCollapsed = false; + scope.$digest(); + $animate.flush(); + initCallbacks(); + scope.isCollapsed = true; + scope.$digest(); + $animate.flush(); + expect(element.height()).toBe(0); + assertCallbacks({ collapsing: true, collapsed: true }); + }); + + it('should change aria-expanded attribute', function() { + scope.isCollapsed = false; + compileFn(scope); + scope.$digest(); + expect(element.attr('aria-expanded')).toBe('true'); + + scope.isCollapsed = true; + scope.$digest(); + $animate.flush(); + expect(element.attr('aria-expanded')).toBe('false'); + }); + + it('should change aria-hidden attribute', function() { + scope.isCollapsed = false; + compileFn(scope); + scope.$digest(); + expect(element.attr('aria-hidden')).toBe('false'); + + scope.isCollapsed = true; + scope.$digest(); + $animate.flush(); + expect(element.attr('aria-hidden')).toBe('true'); }); describe('dynamic content', function() { + var element; + beforeEach(function() { - element = angular.element('Initial contentAdditional content'); + element = angular.element('Initial contentAdditional content'); $compile(element)(scope); angular.element(document.body).append(element); }); @@ -85,22 +193,96 @@ describe('collapse directive', function () { scope.$digest(); expect(element.height()).toBeLessThan(collapseHeight); }); + }); + + describe('expanding callback returning a promise', function() { + var defer, collapsedHeight; + + beforeEach(function() { + defer = $q.defer(); - it('should shrink accordingly when content size inside collapse decreases on subsequent use', function() { + scope.isCollapsed = true; + scope.expanding = function() { + return defer.promise; + }; + compileFn(scope); + scope.$digest(); + collapsedHeight = element.height(); + + // set flag to expand ... scope.isCollapsed = false; - scope.exp = false; scope.$digest(); - scope.isCollapsed = true; + + // ... shouldn't expand yet ... + expect(element.attr('aria-expanded')).not.toBe('true'); + expect(element.height()).toBe(collapsedHeight); + }); + + it('should wait for it to resolve before animating', function() { + defer.resolve(); + + // should now expand scope.$digest(); + $animate.flush(); + + expect(element.attr('aria-expanded')).toBe('true'); + expect(element.height()).toBeGreaterThan(collapsedHeight); + }); + + it('should not animate if it rejects', function() { + defer.reject(); + + // should NOT expand + scope.$digest(); + + expect(element.attr('aria-expanded')).not.toBe('true'); + expect(element.height()).toBe(collapsedHeight); + }); + }); + + describe('collapsing callback returning a promise', function() { + var defer, expandedHeight; + + beforeEach(function() { + defer = $q.defer(); scope.isCollapsed = false; + scope.collapsing = function() { + return defer.promise; + }; + compileFn(scope); scope.$digest(); - $timeout.flush(); - scope.exp = true; + + expandedHeight = element.height(); + + // set flag to collapse ... + scope.isCollapsed = true; scope.$digest(); - var collapseHeight = element.height(); - scope.exp = false; + + // ... but it shouldn't collapse yet ... + expect(element.attr('aria-expanded')).not.toBe('false'); + expect(element.height()).toBe(expandedHeight); + }); + + it('should wait for it to resolve before animating', function() { + defer.resolve(); + + // should now collapse scope.$digest(); - expect(element.height()).toBeLessThan(collapseHeight); + $animate.flush(); + + expect(element.attr('aria-expanded')).toBe('false'); + expect(element.height()).toBeLessThan(expandedHeight); + }); + + it('should not animate if it rejects', function() { + defer.reject(); + + // should NOT collapse + scope.$digest(); + + expect(element.attr('aria-expanded')).not.toBe('false'); + expect(element.height()).toBe(expandedHeight); }); }); -}); \ No newline at end of file + +}); diff --git a/src/collapse/test/collapseHorizontally.spec.js b/src/collapse/test/collapseHorizontally.spec.js new file mode 100644 index 0000000000..e5b22bdb56 --- /dev/null +++ b/src/collapse/test/collapseHorizontally.spec.js @@ -0,0 +1,255 @@ +describe('collapse directive', function() { + var elementH, compileFnH, scope, $compile, $animate, $q; + + beforeEach(module('ui.bootstrap.collapse')); + beforeEach(module('ngAnimateMock')); + beforeEach(inject(function(_$rootScope_, _$compile_, _$animate_, _$q_) { + scope = _$rootScope_; + $compile = _$compile_; + $animate = _$animate_; + $q = _$q_; + })); + + beforeEach(function() { + elementH = angular.element( + '' + + 'Some Content'); + compileFnH = $compile(elementH); + angular.element(document.body).append(elementH); + }); + + afterEach(function() { + elementH.remove(); + }); + + function initCallbacks() { + scope.collapsing = jasmine.createSpy('scope.collapsing'); + scope.collapsed = jasmine.createSpy('scope.collapsed'); + scope.expanding = jasmine.createSpy('scope.expanding'); + scope.expanded = jasmine.createSpy('scope.expanded'); + } + + function assertCallbacks(expected) { + ['collapsing', 'collapsed', 'expanding', 'expanded'].forEach(function(cbName) { + if (expected[cbName]) { + expect(scope[cbName]).toHaveBeenCalled(); + } else { + expect(scope[cbName]).not.toHaveBeenCalled(); + } + }); + } + + it('should be hidden on initialization if isCollapsed = true', function() { + initCallbacks(); + scope.isCollapsed = true; + compileFnH(scope); + scope.$digest(); + expect(elementH.width()).toBe(0); + assertCallbacks({ collapsed: true }); + }); + + it('should not trigger any animation on initialization if isCollapsed = true', function() { + var wrapperFn = function() { + $animate.flush(); + }; + + scope.isCollapsed = true; + compileFnH(scope); + scope.$digest(); + + expect(wrapperFn).toThrowError(/No pending animations ready to be closed or flushed/); + }); + + it('should collapse if isCollapsed = true on subsequent use', function() { + scope.isCollapsed = false; + compileFnH(scope); + scope.$digest(); + initCallbacks(); + scope.isCollapsed = true; + scope.$digest(); + $animate.flush(); + expect(elementH.width()).toBe(0); + assertCallbacks({ collapsing: true, collapsed: true }); + }); + + it('should show after toggled from collapsed', function() { + initCallbacks(); + scope.isCollapsed = true; + compileFnH(scope); + scope.$digest(); + expect(elementH.width()).toBe(0); + assertCallbacks({ collapsed: true }); + scope.collapsed.calls.reset(); + + scope.isCollapsed = false; + scope.$digest(); + $animate.flush(); + expect(elementH.width()).not.toBe(0); + assertCallbacks({ expanding: true, expanded: true }); + }); + + it('should not trigger any animation on initialization if isCollapsed = false', function() { + var wrapperFn = function() { + $animate.flush(); + }; + + scope.isCollapsed = false; + compileFnH(scope); + scope.$digest(); + + expect(wrapperFn).toThrowError(/No pending animations ready to be closed or flushed/); + }); + + it('should expand if isCollapsed = false on subsequent use', function() { + scope.isCollapsed = false; + compileFnH(scope); + scope.$digest(); + scope.isCollapsed = true; + scope.$digest(); + $animate.flush(); + initCallbacks(); + scope.isCollapsed = false; + scope.$digest(); + $animate.flush(); + expect(elementH.width()).not.toBe(0); + assertCallbacks({ expanding: true, expanded: true }); + }); + + it('should collapse if isCollapsed = true on subsequent uses', function() { + scope.isCollapsed = false; + compileFnH(scope); + scope.$digest(); + scope.isCollapsed = true; + scope.$digest(); + $animate.flush(); + scope.isCollapsed = false; + scope.$digest(); + $animate.flush(); + initCallbacks(); + scope.isCollapsed = true; + scope.$digest(); + $animate.flush(); + expect(elementH.width()).toBe(0); + assertCallbacks({ collapsing: true, collapsed: true }); + }); + + it('should change aria-expanded attribute', function() { + scope.isCollapsed = false; + compileFnH(scope); + scope.$digest(); + expect(elementH.attr('aria-expanded')).toBe('true'); + + scope.isCollapsed = true; + scope.$digest(); + $animate.flush(); + expect(elementH.attr('aria-expanded')).toBe('false'); + }); + + it('should change aria-hidden attribute', function() { + scope.isCollapsed = false; + compileFnH(scope); + scope.$digest(); + expect(elementH.attr('aria-hidden')).toBe('false'); + + scope.isCollapsed = true; + scope.$digest(); + $animate.flush(); + expect(elementH.attr('aria-hidden')).toBe('true'); + }); + + describe('expanding callback returning a promise', function() { + var defer, collapsedWidth; + + beforeEach(function() { + defer = $q.defer(); + + scope.isCollapsed = true; + scope.expanding = function() { + return defer.promise; + }; + compileFnH(scope); + scope.$digest(); + collapsedWidth = elementH.width(); + + // set flag to expand ... + scope.isCollapsed = false; + scope.$digest(); + + // ... shouldn't expand yet ... + expect(elementH.attr('aria-expanded')).not.toBe('true'); + expect(elementH.width()).toBe(collapsedWidth); + }); + + it('should wait for it to resolve before animating', function() { + defer.resolve(); + + // should now expand + scope.$digest(); + $animate.flush(); + + expect(elementH.attr('aria-expanded')).toBe('true'); + expect(elementH.width()).toBeGreaterThan(collapsedWidth); + }); + + it('should not animate if it rejects', function() { + defer.reject(); + + // should NOT expand + scope.$digest(); + + expect(elementH.attr('aria-expanded')).not.toBe('true'); + expect(elementH.width()).toBe(collapsedWidth); + }); + }); + + describe('collapsing callback returning a promise', function() { + var defer, expandedWidth; + + beforeEach(function() { + defer = $q.defer(); + scope.isCollapsed = false; + scope.collapsing = function() { + return defer.promise; + }; + compileFnH(scope); + scope.$digest(); + + expandedWidth = elementH.width(); + + // set flag to collapse ... + scope.isCollapsed = true; + scope.$digest(); + + // ... but it shouldn't collapse yet ... + expect(elementH.attr('aria-expanded')).not.toBe('false'); + expect(elementH.width()).toBe(expandedWidth); + }); + + it('should wait for it to resolve before animating', function() { + defer.resolve(); + + // should now collapse + scope.$digest(); + $animate.flush(); + + expect(elementH.attr('aria-expanded')).toBe('false'); + expect(elementH.width()).toBeLessThan(expandedWidth); + }); + + it('should not animate if it rejects', function() { + defer.reject(); + + // should NOT collapse + scope.$digest(); + + expect(elementH.attr('aria-expanded')).not.toBe('false'); + expect(elementH.width()).toBe(expandedWidth); + }); + }); + +}); diff --git a/src/dateparser/dateparser.js b/src/dateparser/dateparser.js new file mode 100644 index 0000000000..d770dff31b --- /dev/null +++ b/src/dateparser/dateparser.js @@ -0,0 +1,539 @@ +angular.module('ui.bootstrap.dateparser', []) + +.service('uibDateParser', ['$log', '$locale', 'dateFilter', 'orderByFilter', 'filterFilter', function($log, $locale, dateFilter, orderByFilter, filterFilter) { + // Pulled from https://github.com/mbostock/d3/blob/master/src/format/requote.js + var SPECIAL_CHARACTERS_REGEXP = /[\\\^\$\*\+\?\|\[\]\(\)\.\{\}]/g; + + var localeId; + var formatCodeToRegex; + + this.init = function() { + localeId = $locale.id; + + this.parsers = {}; + this.formatters = {}; + + formatCodeToRegex = [ + { + key: 'yyyy', + regex: '\\d{4}', + apply: function(value) { this.year = +value; }, + formatter: function(date) { + var _date = new Date(); + _date.setFullYear(Math.abs(date.getFullYear())); + return dateFilter(_date, 'yyyy'); + } + }, + { + key: 'yy', + regex: '\\d{2}', + apply: function(value) { value = +value; this.year = value < 69 ? value + 2000 : value + 1900; }, + formatter: function(date) { + var _date = new Date(); + _date.setFullYear(Math.abs(date.getFullYear())); + return dateFilter(_date, 'yy'); + } + }, + { + key: 'y', + regex: '\\d{1,4}', + apply: function(value) { this.year = +value; }, + formatter: function(date) { + var _date = new Date(); + _date.setFullYear(Math.abs(date.getFullYear())); + return dateFilter(_date, 'y'); + } + }, + { + key: 'M!', + regex: '0?[1-9]|1[0-2]', + apply: function(value) { this.month = value - 1; }, + formatter: function(date) { + var value = date.getMonth(); + if (/^[0-9]$/.test(value)) { + return dateFilter(date, 'MM'); + } + + return dateFilter(date, 'M'); + } + }, + { + key: 'MMMM', + regex: $locale.DATETIME_FORMATS.MONTH.join('|'), + apply: function(value) { this.month = $locale.DATETIME_FORMATS.MONTH.indexOf(value); }, + formatter: function(date) { return dateFilter(date, 'MMMM'); } + }, + { + key: 'MMM', + regex: $locale.DATETIME_FORMATS.SHORTMONTH.join('|'), + apply: function(value) { this.month = $locale.DATETIME_FORMATS.SHORTMONTH.indexOf(value); }, + formatter: function(date) { return dateFilter(date, 'MMM'); } + }, + { + key: 'MM', + regex: '0[1-9]|1[0-2]', + apply: function(value) { this.month = value - 1; }, + formatter: function(date) { return dateFilter(date, 'MM'); } + }, + { + key: 'M', + regex: '[1-9]|1[0-2]', + apply: function(value) { this.month = value - 1; }, + formatter: function(date) { return dateFilter(date, 'M'); } + }, + { + key: 'd!', + regex: '[0-2]?[0-9]{1}|3[0-1]{1}', + apply: function(value) { this.date = +value; }, + formatter: function(date) { + var value = date.getDate(); + if (/^[1-9]$/.test(value)) { + return dateFilter(date, 'dd'); + } + + return dateFilter(date, 'd'); + } + }, + { + key: 'dd', + regex: '[0-2][0-9]{1}|3[0-1]{1}', + apply: function(value) { this.date = +value; }, + formatter: function(date) { return dateFilter(date, 'dd'); } + }, + { + key: 'd', + regex: '[1-2]?[0-9]{1}|3[0-1]{1}', + apply: function(value) { this.date = +value; }, + formatter: function(date) { return dateFilter(date, 'd'); } + }, + { + key: 'EEEE', + regex: $locale.DATETIME_FORMATS.DAY.join('|'), + formatter: function(date) { return dateFilter(date, 'EEEE'); } + }, + { + key: 'EEE', + regex: $locale.DATETIME_FORMATS.SHORTDAY.join('|'), + formatter: function(date) { return dateFilter(date, 'EEE'); } + }, + { + key: 'HH', + regex: '(?:0|1)[0-9]|2[0-3]', + apply: function(value) { this.hours = +value; }, + formatter: function(date) { return dateFilter(date, 'HH'); } + }, + { + key: 'hh', + regex: '0[0-9]|1[0-2]', + apply: function(value) { this.hours = +value; }, + formatter: function(date) { return dateFilter(date, 'hh'); } + }, + { + key: 'H', + regex: '1?[0-9]|2[0-3]', + apply: function(value) { this.hours = +value; }, + formatter: function(date) { return dateFilter(date, 'H'); } + }, + { + key: 'h', + regex: '[0-9]|1[0-2]', + apply: function(value) { this.hours = +value; }, + formatter: function(date) { return dateFilter(date, 'h'); } + }, + { + key: 'mm', + regex: '[0-5][0-9]', + apply: function(value) { this.minutes = +value; }, + formatter: function(date) { return dateFilter(date, 'mm'); } + }, + { + key: 'm', + regex: '[0-9]|[1-5][0-9]', + apply: function(value) { this.minutes = +value; }, + formatter: function(date) { return dateFilter(date, 'm'); } + }, + { + key: 'sss', + regex: '[0-9][0-9][0-9]', + apply: function(value) { this.milliseconds = +value; }, + formatter: function(date) { return dateFilter(date, 'sss'); } + }, + { + key: 'ss', + regex: '[0-5][0-9]', + apply: function(value) { this.seconds = +value; }, + formatter: function(date) { return dateFilter(date, 'ss'); } + }, + { + key: 's', + regex: '[0-9]|[1-5][0-9]', + apply: function(value) { this.seconds = +value; }, + formatter: function(date) { return dateFilter(date, 's'); } + }, + { + key: 'a', + regex: $locale.DATETIME_FORMATS.AMPMS.join('|'), + apply: function(value) { + if (this.hours === 12) { + this.hours = 0; + } + + if (value === 'PM') { + this.hours += 12; + } + }, + formatter: function(date) { return dateFilter(date, 'a'); } + }, + { + key: 'Z', + regex: '[+-]\\d{4}', + apply: function(value) { + var matches = value.match(/([+-])(\d{2})(\d{2})/), + sign = matches[1], + hours = matches[2], + minutes = matches[3]; + this.hours += toInt(sign + hours); + this.minutes += toInt(sign + minutes); + }, + formatter: function(date) { + return dateFilter(date, 'Z'); + } + }, + { + key: 'ww', + regex: '[0-4][0-9]|5[0-3]', + formatter: function(date) { return dateFilter(date, 'ww'); } + }, + { + key: 'w', + regex: '[0-9]|[1-4][0-9]|5[0-3]', + formatter: function(date) { return dateFilter(date, 'w'); } + }, + { + key: 'GGGG', + regex: $locale.DATETIME_FORMATS.ERANAMES.join('|').replace(/\s/g, '\\s'), + formatter: function(date) { return dateFilter(date, 'GGGG'); } + }, + { + key: 'GGG', + regex: $locale.DATETIME_FORMATS.ERAS.join('|'), + formatter: function(date) { return dateFilter(date, 'GGG'); } + }, + { + key: 'GG', + regex: $locale.DATETIME_FORMATS.ERAS.join('|'), + formatter: function(date) { return dateFilter(date, 'GG'); } + }, + { + key: 'G', + regex: $locale.DATETIME_FORMATS.ERAS.join('|'), + formatter: function(date) { return dateFilter(date, 'G'); } + } + ]; + + if (angular.version.major >= 1 && angular.version.minor > 4) { + formatCodeToRegex.push({ + key: 'LLLL', + regex: $locale.DATETIME_FORMATS.STANDALONEMONTH.join('|'), + apply: function(value) { this.month = $locale.DATETIME_FORMATS.STANDALONEMONTH.indexOf(value); }, + formatter: function(date) { return dateFilter(date, 'LLLL'); } + }); + } + }; + + this.init(); + + function getFormatCodeToRegex(key) { + return filterFilter(formatCodeToRegex, {key: key}, true)[0]; + } + + this.getParser = function (key) { + var f = getFormatCodeToRegex(key); + return f && f.apply || null; + }; + + this.overrideParser = function (key, parser) { + var f = getFormatCodeToRegex(key); + if (f && angular.isFunction(parser)) { + this.parsers = {}; + f.apply = parser; + } + }.bind(this); + + function createParser(format) { + var map = [], regex = format.split(''); + + // check for literal values + var quoteIndex = format.indexOf('\''); + if (quoteIndex > -1) { + var inLiteral = false; + format = format.split(''); + for (var i = quoteIndex; i < format.length; i++) { + if (inLiteral) { + if (format[i] === '\'') { + if (i + 1 < format.length && format[i+1] === '\'') { // escaped single quote + format[i+1] = '$'; + regex[i+1] = ''; + } else { // end of literal + regex[i] = ''; + inLiteral = false; + } + } + format[i] = '$'; + } else { + if (format[i] === '\'') { // start of literal + format[i] = '$'; + regex[i] = ''; + inLiteral = true; + } + } + } + + format = format.join(''); + } + + angular.forEach(formatCodeToRegex, function(data) { + var index = format.indexOf(data.key); + + if (index > -1) { + format = format.split(''); + + regex[index] = '(' + data.regex + ')'; + format[index] = '$'; // Custom symbol to define consumed part of format + for (var i = index + 1, n = index + data.key.length; i < n; i++) { + regex[i] = ''; + format[i] = '$'; + } + format = format.join(''); + + map.push({ + index: index, + key: data.key, + apply: data.apply, + matcher: data.regex + }); + } + }); + + return { + regex: new RegExp('^' + regex.join('') + '$'), + map: orderByFilter(map, 'index') + }; + } + + function createFormatter(format) { + var formatters = []; + var i = 0; + var formatter, literalIdx; + while (i < format.length) { + if (angular.isNumber(literalIdx)) { + if (format.charAt(i) === '\'') { + if (i + 1 >= format.length || format.charAt(i + 1) !== '\'') { + formatters.push(constructLiteralFormatter(format, literalIdx, i)); + literalIdx = null; + } + } else if (i === format.length) { + while (literalIdx < format.length) { + formatter = constructFormatterFromIdx(format, literalIdx); + formatters.push(formatter); + literalIdx = formatter.endIdx; + } + } + + i++; + continue; + } + + if (format.charAt(i) === '\'') { + literalIdx = i; + i++; + continue; + } + + formatter = constructFormatterFromIdx(format, i); + + formatters.push(formatter.parser); + i = formatter.endIdx; + } + + return formatters; + } + + function constructLiteralFormatter(format, literalIdx, endIdx) { + return function() { + return format.substr(literalIdx + 1, endIdx - literalIdx - 1); + }; + } + + function constructFormatterFromIdx(format, i) { + var currentPosStr = format.substr(i); + for (var j = 0; j < formatCodeToRegex.length; j++) { + if (new RegExp('^' + formatCodeToRegex[j].key).test(currentPosStr)) { + var data = formatCodeToRegex[j]; + return { + endIdx: i + data.key.length, + parser: data.formatter + }; + } + } + + return { + endIdx: i + 1, + parser: function() { + return currentPosStr.charAt(0); + } + }; + } + + this.filter = function(date, format) { + if (!angular.isDate(date) || isNaN(date) || !format) { + return ''; + } + + format = $locale.DATETIME_FORMATS[format] || format; + + if ($locale.id !== localeId) { + this.init(); + } + + if (!this.formatters[format]) { + this.formatters[format] = createFormatter(format); + } + + var formatters = this.formatters[format]; + + return formatters.reduce(function(str, formatter) { + return str + formatter(date); + }, ''); + }; + + this.parse = function(input, format, baseDate) { + if (!angular.isString(input) || !format) { + return input; + } + + format = $locale.DATETIME_FORMATS[format] || format; + format = format.replace(SPECIAL_CHARACTERS_REGEXP, '\\$&'); + + if ($locale.id !== localeId) { + this.init(); + } + + if (!this.parsers[format]) { + this.parsers[format] = createParser(format, 'apply'); + } + + var parser = this.parsers[format], + regex = parser.regex, + map = parser.map, + results = input.match(regex), + tzOffset = false; + if (results && results.length) { + var fields, dt; + if (angular.isDate(baseDate) && !isNaN(baseDate.getTime())) { + fields = { + year: baseDate.getFullYear(), + month: baseDate.getMonth(), + date: baseDate.getDate(), + hours: baseDate.getHours(), + minutes: baseDate.getMinutes(), + seconds: baseDate.getSeconds(), + milliseconds: baseDate.getMilliseconds() + }; + } else { + if (baseDate) { + $log.warn('dateparser:', 'baseDate is not a valid date'); + } + fields = { year: 1900, month: 0, date: 1, hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }; + } + + for (var i = 1, n = results.length; i < n; i++) { + var mapper = map[i - 1]; + if (mapper.matcher === 'Z') { + tzOffset = true; + } + + if (mapper.apply) { + mapper.apply.call(fields, results[i]); + } + } + + var datesetter = tzOffset ? Date.prototype.setUTCFullYear : + Date.prototype.setFullYear; + var timesetter = tzOffset ? Date.prototype.setUTCHours : + Date.prototype.setHours; + + if (isValid(fields.year, fields.month, fields.date)) { + if (angular.isDate(baseDate) && !isNaN(baseDate.getTime()) && !tzOffset) { + dt = new Date(baseDate); + datesetter.call(dt, fields.year, fields.month, fields.date); + timesetter.call(dt, fields.hours, fields.minutes, + fields.seconds, fields.milliseconds); + } else { + dt = new Date(0); + datesetter.call(dt, fields.year, fields.month, fields.date); + timesetter.call(dt, fields.hours || 0, fields.minutes || 0, + fields.seconds || 0, fields.milliseconds || 0); + } + } + + return dt; + } + }; + + // Check if date is valid for specific month (and year for February). + // Month: 0 = Jan, 1 = Feb, etc + function isValid(year, month, date) { + if (date < 1) { + return false; + } + + if (month === 1 && date > 28) { + return date === 29 && (year % 4 === 0 && year % 100 !== 0 || year % 400 === 0); + } + + if (month === 3 || month === 5 || month === 8 || month === 10) { + return date < 31; + } + + return true; + } + + function toInt(str) { + return parseInt(str, 10); + } + + this.toTimezone = toTimezone; + this.fromTimezone = fromTimezone; + this.timezoneToOffset = timezoneToOffset; + this.addDateMinutes = addDateMinutes; + this.convertTimezoneToLocal = convertTimezoneToLocal; + + function toTimezone(date, timezone) { + return date && timezone ? convertTimezoneToLocal(date, timezone) : date; + } + + function fromTimezone(date, timezone) { + return date && timezone ? convertTimezoneToLocal(date, timezone, true) : date; + } + + //https://github.com/angular/angular.js/blob/622c42169699ec07fc6daaa19fe6d224e5d2f70e/src/Angular.js#L1207 + function timezoneToOffset(timezone, fallback) { + timezone = timezone.replace(/:/g, ''); + var requestedTimezoneOffset = Date.parse('Jan 01, 1970 00:00:00 ' + timezone) / 60000; + return isNaN(requestedTimezoneOffset) ? fallback : requestedTimezoneOffset; + } + + function addDateMinutes(date, minutes) { + date = new Date(date.getTime()); + date.setMinutes(date.getMinutes() + minutes); + return date; + } + + function convertTimezoneToLocal(date, timezone, reverse) { + reverse = reverse ? -1 : 1; + var dateTimezoneOffset = date.getTimezoneOffset(); + var timezoneOffset = timezoneToOffset(timezone, dateTimezoneOffset); + return addDateMinutes(date, reverse * (timezoneOffset - dateTimezoneOffset)); + } +}]); diff --git a/src/dateparser/docs/README.md b/src/dateparser/docs/README.md new file mode 100644 index 0000000000..f7a3b3434f --- /dev/null +++ b/src/dateparser/docs/README.md @@ -0,0 +1,147 @@ +The `uibDateParser` is what the `uib-datepicker` uses internally to parse the dates. You can use it standalone by injecting the `uibDateParser` service where you need it. + +The public API for the dateParser is a single method called `parse`. + +Certain format codes support i18n. Check this [guide](https://docs.angularjs.org/guide/i18n) for more information. + +### uibDateParser's parse function + +##### parameters + +* `input` + _(Type: `string`, Example: `2004/Sep/4`)_ - + The input date to parse. + +* `format` + _(Type: `string`, Example: `yyyy/MMM/d`)_ - + The format we want to use. Check all the supported formats below. + +* `baseDate` + _(Type: `Date`, Example: `new Date()`)_ - + If you want to parse a date but maintain the timezone, you can pass an existing date here. + +##### return + +* If the specified input matches the format, a new date with the input will be returned, otherwise, it will return undefined. + +### uibDateParser's format codes + +* `yyyy` + _(Example: `2015`)_ - + Parses a 4 digits year. + +* `yy` + _(Example: `15`)_ - + Parses a 2 digits year. + +* `y` + _(Example: `15`)_ - + Parses a year with 1, 2, 3, or 4 digits. + +* `MMMM` + _(Example: `February`, i18n support)_ - + Parses the full name of a month. + +* `MMM` + _(Example: `Feb`, i18n support)_ - + Parses the short name of a month. + +* `MM` + _(Example: `12`, Leading 0)_ - + Parses a numeric month. + +* `M` + _(Example: `3`)_ - + Parses a numeric month. + +* `M!` + _(Example: `3` or `03`)_ - + Parses a numeric month, but allowing an optional leading zero + +* `LLLL` + _(Example: `February`, i18n support)_ - Stand-alone month in year (January-December). Requires Angular version 1.5.1 or higher. + +* `dd` + _(Example: `05`, Leading 0)_ - + Parses a numeric day. + +* `d` + _(Example: `5`)_ - + Parses a numeric day. + +* `d!` + _(Example: `3` or `03`)_ - + Parses a numeric day, but allowing an optional leading zero + +* `EEEE` + _(Example: `Sunday`, i18n support)_ - + Parses the full name of a day. + +* `EEE` + _(Example: `Mon`, i18n support)_ - + Parses the short name of a day. + +* `HH` + _(Example: `14`, Leading 0)_ - + Parses a 24 hours time. + +* `H` + _(Example: `3`)_ - + Parses a 24 hours time. + +* `hh` + _(Example: `11`, Leading 0)_ - + Parses a 12 hours time. + +* `h` + _(Example: `3`)_ - + Parses a 12 hours time. + +* `mm` + _(Example: `09`, Leading 0)_ - + Parses the minutes. + +* `m` + _(Example: `3`)_ - + Parses the minutes. + +* `sss` + _(Example: `094`, Leading 0)_ - + Parses the milliseconds. + +* `ss` + _(Example: `08`, Leading 0)_ - + Parses the seconds. + +* `s` + _(Example: `22`)_ - + Parses the seconds. + +* `a` + _(Example: `10AM`)_ - + Parses a 12 hours time with AM/PM. + +* `Z` + _(Example: `-0800`)_ - + Parses the timezone offset in a signed 4 digit representation + +* `ww` + _(Example: `03`, Leading 0)_ - + Parses the week number + +* `w` + _(Example: `03`)_ - + Parses the week number + +* `G`, `GG`, `GGG` + _(Example: `AD`)_ - + Parses the era (`AD` or `BC`) +* `GGGG` + _(Example: `Anno Domini`)_ - + Parses the long form of the era (`Anno Domini` or `Before Christ`) + +\* The ones marked with `Leading 0`, needs a leading 0 for values less than 10. Exception being milliseconds which needs it for values under 100. + +\** It also supports `fullDate|longDate|medium|mediumDate|mediumTime|short|shortDate|shortTime` as the format for parsing. + +\*** It supports template literals as a string between the single quote `'` character, i.e. `'The Date is' MM/DD/YYYY`. If one wants the literal single quote character, one must use `''''`. diff --git a/src/dateparser/docs/demo.html b/src/dateparser/docs/demo.html new file mode 100644 index 0000000000..2467d43206 --- /dev/null +++ b/src/dateparser/docs/demo.html @@ -0,0 +1,11 @@ + + Formatting codes playground + + Define your format + + + + Result + + + diff --git a/src/dateparser/docs/demo.js b/src/dateparser/docs/demo.js new file mode 100644 index 0000000000..df9976ea99 --- /dev/null +++ b/src/dateparser/docs/demo.js @@ -0,0 +1,4 @@ +angular.module('ui.bootstrap.demo').controller('DateParserDemoCtrl', function ($scope, uibDateParser) { + $scope.format = 'yyyy/MM/dd'; + $scope.date = new Date(); +}); diff --git a/src/dateparser/index.js b/src/dateparser/index.js new file mode 100644 index 0000000000..2320dec7d8 --- /dev/null +++ b/src/dateparser/index.js @@ -0,0 +1,7 @@ +require('./dateparser'); + +var MODULE_NAME = 'ui.bootstrap.module.dateparser'; + +angular.module(MODULE_NAME, ['ui.bootstrap.dateparser']); + +module.exports = MODULE_NAME; diff --git a/src/dateparser/test/dateparser.spec.js b/src/dateparser/test/dateparser.spec.js new file mode 100644 index 0000000000..70c4b3e4f8 --- /dev/null +++ b/src/dateparser/test/dateparser.spec.js @@ -0,0 +1,818 @@ +describe('date parser', function() { + var dateParser, oldDate; + + beforeEach(module('ui.bootstrap.dateparser')); + beforeEach(inject(function (uibDateParser) { + dateParser = uibDateParser; + oldDate = new Date(1, 2, 6); + oldDate.setFullYear(1); + })); + + function expectFilter(date, format, display) { + expect(dateParser.filter(date, format)).toEqual(display); + } + + function expectParse(input, format, date) { + expect(dateParser.parse(input, format)).toEqual(date); + } + + function expectBaseParse(input, format, baseDate, date) { + expect(dateParser.parse(input, format, baseDate)).toEqual(date); + } + + describe('filter', function() { + it('should work correctly for `dd`, `MM`, `yyyy`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'dd.MM.yyyy', '17.11.2013'); + expectFilter(new Date(2013, 11, 31, 0), 'dd.MM.yyyy', '31.12.2013'); + expectFilter(new Date(1991, 2, 8, 0), 'dd-MM-yyyy', '08-03-1991'); + expectFilter(new Date(1980, 2, 5, 0), 'MM/dd/yyyy', '03/05/1980'); + expectFilter(new Date(1983, 0, 10, 0), 'dd.MM/yyyy', '10.01/1983'); + expectFilter(new Date(1980, 10, 9, 0), 'MM-dd-yyyy', '11-09-1980'); + expectFilter(new Date(2011, 1, 5, 0), 'yyyy/MM/dd', '2011/02/05'); + expectFilter(oldDate, 'yyyy/MM/dd', '0001/03/06'); + }); + + it('should work correctly for `yy`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'dd.MM.yy', '17.11.13'); + expectFilter(new Date(2011, 4, 2, 0), 'dd-MM-yy', '02-05-11'); + expectFilter(new Date(2080, 1, 5, 0), 'MM/dd/yy', '02/05/80'); + expectFilter(new Date(2055, 1, 5, 0), 'yy/MM/dd', '55/02/05'); + expectFilter(new Date(2013, 7, 11, 0), 'dd-MM-yy', '11-08-13'); + }); + + it('should work correctly for `y`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'dd.MM.y', '17.11.2013'); + expectFilter(new Date(2013, 11, 31, 0), 'dd.MM.y', '31.12.2013'); + expectFilter(new Date(1991, 2, 8, 0), 'dd-MM-y', '08-03-1991'); + expectFilter(new Date(1980, 2, 5, 0), 'MM/dd/y', '03/05/1980'); + expectFilter(new Date(1983, 0, 10, 0), 'dd.MM/y', '10.01/1983'); + expectFilter(new Date(1980, 10, 9, 0), 'MM-dd-y', '11-09-1980'); + expectFilter(new Date(2011, 1, 5, 0), 'y/MM/dd', '2011/02/05'); + }); + + it('should work correctly for `MMMM`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'dd.MMMM.yy', '17.November.13'); + expectFilter(new Date(1980, 2, 5, 0), 'dd-MMMM-yyyy', '05-March-1980'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/dd/yyyy', 'February/05/1980'); + expectFilter(new Date(1949, 11, 20, 0), 'yyyy/MMMM/dd', '1949/December/20'); + expectFilter(oldDate, 'yyyy/MMMM/dd', '0001/March/06'); + }); + + it('should work correctly for `MMM`', function() { + expectFilter(new Date(2010, 8, 30, 0), 'dd.MMM.yy', '30.Sep.10'); + expectFilter(new Date(2011, 4, 2, 0), 'dd-MMM-yy', '02-May-11'); + expectFilter(new Date(1980, 1, 5, 0), 'MMM/dd/yyyy', 'Feb/05/1980'); + expectFilter(new Date(1955, 1, 5, 0), 'yyyy/MMM/dd', '1955/Feb/05'); + expectFilter(oldDate, 'yyyy/MMM/dd', '0001/Mar/06'); + }); + + it('should work correctly for `M`', function() { + expectFilter(new Date(2013, 7, 11, 0), 'M/dd/yyyy', '8/11/2013'); + expectFilter(new Date(2005, 10, 7, 0), 'dd.M.yy', '07.11.05'); + expectFilter(new Date(2011, 4, 2, 0), 'dd-M-yy', '02-5-11'); + expectFilter(new Date(1980, 1, 5, 0), 'M/dd/yyyy', '2/05/1980'); + expectFilter(new Date(1955, 1, 5, 0), 'yyyy/M/dd', '1955/2/05'); + expectFilter(new Date(2011, 4, 2, 0), 'dd-M-yy', '02-5-11'); + }); + + it('should work correctly for `M!`', function() { + expectFilter(new Date(2013, 7, 11, 0), 'M!/dd/yyyy', '08/11/2013'); + expectFilter(new Date(2005, 10, 7, 0), 'dd.M!.yy', '07.11.05'); + expectFilter(new Date(2011, 4, 2, 0), 'dd-M!-yy', '02-05-11'); + expectFilter(new Date(1980, 1, 5, 0), 'M!/dd/yyyy', '02/05/1980'); + expectFilter(new Date(1955, 1, 5, 0), 'yyyy/M!/dd', '1955/02/05'); + expectFilter(new Date(2011, 4, 2, 0), 'dd-M!-yy', '02-05-11'); + expectFilter(oldDate, 'yyyy/M!/dd', '0001/03/06'); + }); + + it('should work correctly for `LLLL`', function() { + expectFilter(new Date(2013, 7, 24, 0), 'LLLL/dd/yyyy', 'August/24/2013'); + expectFilter(new Date(2004, 10, 7, 0), 'dd.LLLL.yy', '07.November.04'); + expectFilter(new Date(2011, 4, 18, 0), 'dd-LLLL-yy', '18-May-11'); + expectFilter(new Date(1980, 1, 5, 0), 'LLLL/dd/yyyy', 'February/05/1980'); + expectFilter(new Date(1955, 2, 5, 0), 'yyyy/LLLL/dd', '1955/March/05'); + expectFilter(new Date(2011, 5, 2, 0), 'dd-LLLL-yy', '02-June-11'); + expectFilter(oldDate, 'yyyy/LLLL/dd', '0001/March/06'); + }); + + it('should work correctly for `d`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'd.MMMM.yy', '17.November.13'); + expectFilter(new Date(1991, 2, 8, 0), 'd-MMMM-yyyy', '8-March-1991'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy', 'February/5/1980'); + expectFilter(new Date(1955, 1, 5, 0), 'yyyy/MMMM/d', '1955/February/5'); + expectFilter(new Date(2013, 7, 11, 0), 'd-MM-yy', '11-08-13'); + expectFilter(oldDate, 'yyyy/MM/d', '0001/03/6'); + }); + + it('should work correctly for `d!`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'd!.MMMM.yy', '17.November.13'); + expectFilter(new Date(1991, 2, 8, 0), 'd!-MMMM-yyyy', '08-March-1991'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d!/yyyy', 'February/05/1980'); + expectFilter(new Date(1955, 1, 5, 0), 'yyyy/MMMM/d!', '1955/February/05'); + expectFilter(new Date(2013, 7, 11, 0), 'd!-MM-yy', '11-08-13'); + expectFilter(oldDate, 'yyyy/MM/d!', '0001/03/06'); + }); + + it('should work correctly for `EEEE`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'EEEE.d.MMMM.yy', 'Sunday.17.November.13'); + expectFilter(new Date(1991, 2, 8, 0), 'd-EEEE-MMMM-yyyy', '8-Friday-March-1991'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/EEEE', 'February/5/1980/Tuesday'); + expectFilter(new Date(1955, 1, 5, 0), 'yyyy/EEEE/MMMM/d', '1955/Saturday/February/5'); + }); + + it('should work correctly for `EEE`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'EEE.d.MMMM.yy', 'Sun.17.November.13'); + expectFilter(new Date(1991, 2, 8, 0), 'd-EEE-MMMM-yyyy', '8-Fri-March-1991'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/EEE', 'February/5/1980/Tue'); + expectFilter(new Date(1955, 1, 5, 0), 'yyyy/EEE/MMMM/d', '1955/Sat/February/5'); + }); + + it('should work correctly for `HH`', function() { + expectFilter(new Date(2015, 2, 22, 22), 'd.MMMM.yy.HH', '22.March.15.22'); + expectFilter(new Date(1991, 2, 8, 11), 'd-MMMM-yyyy-HH', '8-March-1991-11'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/HH', 'February/5/1980/00'); + expectFilter(new Date(1955, 1, 5, 3), 'yyyy/MMMM/d HH', '1955/February/5 03'); + expectFilter(new Date(2013, 7, 11, 23), 'd-MM-yy HH', '11-08-13 23'); + }); + + it('should work correctly for `H`', function() { + expectFilter(new Date(2015, 2, 22, 22), 'd.MMMM.yy.H', '22.March.15.22'); + expectFilter(new Date(1991, 2, 8, 11), 'd-MMMM-yyyy-H', '8-March-1991-11'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/H', 'February/5/1980/0'); + expectFilter(new Date(1955, 1, 5, 3), 'yyyy/MMMM/d H', '1955/February/5 3'); + expectFilter(new Date(2013, 7, 11, 23), 'd-MM-yy H', '11-08-13 23'); + }); + + it('should work correctly for `hh`', function() { + expectFilter(new Date(2015, 2, 22, 12), 'd.MMMM.yy.hh', '22.March.15.12'); + expectFilter(new Date(1991, 2, 8, 11), 'd-MMMM-yyyy-hh', '8-March-1991-11'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/hh', 'February/5/1980/12'); + expectFilter(new Date(1955, 1, 5, 3), 'yyyy/MMMM/d hh', '1955/February/5 03'); + expectFilter(new Date(2013, 7, 11, 9), 'd-MM-yy hh', '11-08-13 09'); + }); + + it('should work correctly for `h`', function() { + expectFilter(new Date(2015, 2, 22, 12), 'd.MMMM.yy.h', '22.March.15.12'); + expectFilter(new Date(1991, 2, 8, 11), 'd-MMMM-yyyy-h', '8-March-1991-11'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/h', 'February/5/1980/12'); + expectFilter(new Date(1955, 1, 5, 3), 'yyyy/MMMM/d h', '1955/February/5 3'); + expectFilter(new Date(2013, 7, 11, 3), 'd-MM-yy h', '11-08-13 3'); + }); + + it('should work correctly for `mm`', function() { + expectFilter(new Date(2015, 2, 22, 0, 22), 'd.MMMM.yy.mm', '22.March.15.22'); + expectFilter(new Date(1991, 2, 8, 0, 59), 'd-MMMM-yyyy-mm', '8-March-1991-59'); + expectFilter(new Date(1980, 1, 5, 0, 0), 'MMMM/d/yyyy/mm', 'February/5/1980/00'); + expectFilter(new Date(1955, 1, 5, 0, 3), 'yyyy/MMMM/d mm', '1955/February/5 03'); + expectFilter(new Date(2013, 7, 11, 0, 46), 'd-MM-yy mm', '11-08-13 46'); + expectFilter(new Date(2015, 2, 22, 22, 33), 'd.MMMM.yy.HH:mm', '22.March.15.22:33'); + expectFilter(new Date(2015, 2, 22, 2, 1), 'd.MMMM.yy.H:mm', '22.March.15.2:01'); + }); + + it('should work correctly for `m`', function() { + expectFilter(new Date(2015, 2, 22, 0, 22), 'd.MMMM.yy.m', '22.March.15.22'); + expectFilter(new Date(1991, 2, 8, 0, 59), 'd-MMMM-yyyy-m', '8-March-1991-59'); + expectFilter(new Date(1980, 1, 5, 0, 0), 'MMMM/d/yyyy/m', 'February/5/1980/0'); + expectFilter(new Date(1955, 1, 5, 0, 3), 'yyyy/MMMM/d m', '1955/February/5 3'); + expectFilter(new Date(2013, 7, 11, 0, 46), 'd-MM-yy m', '11-08-13 46'); + expectFilter(new Date(2015, 2, 22, 22, 3), 'd.MMMM.yy.HH:m', '22.March.15.22:3'); + expectFilter(new Date(2015, 2, 22, 2, 1), 'd.MMMM.yy.H:m', '22.March.15.2:1'); + }); + + it('should work correctly for `sss`', function() { + expectFilter(new Date(2015, 2, 22, 0, 0, 0, 123), 'd.MMMM.yy.sss', '22.March.15.123'); + expectFilter(new Date(1991, 2, 8, 0, 0, 0, 59), 'd-MMMM-yyyy-sss', '8-March-1991-059'); + expectFilter(new Date(1980, 1, 5, 0, 0, 0), 'MMMM/d/yyyy/sss', 'February/5/1980/000'); + expectFilter(new Date(1955, 1, 5, 0, 0, 0, 3), 'yyyy/MMMM/d sss', '1955/February/5 003'); + expectFilter(new Date(2013, 7, 11, 0, 0, 0, 46), 'd-MM-yy sss', '11-08-13 046'); + expectFilter(new Date(2015, 2, 22, 22, 33, 0, 44), 'd.MMMM.yy.HH:mm:sss', '22.March.15.22:33:044'); + expectFilter(new Date(2015, 2, 22, 0, 0, 0, 1), 'd.MMMM.yy.H:m:sss', '22.March.15.0:0:001'); + }); + + it('should work correctly for `ss`', function() { + expectFilter(new Date(2015, 2, 22, 0, 0, 22), 'd.MMMM.yy.ss', '22.March.15.22'); + expectFilter(new Date(1991, 2, 8, 0, 0, 59), 'd-MMMM-yyyy-ss', '8-March-1991-59'); + expectFilter(new Date(1980, 1, 5, 0, 0, 0), 'MMMM/d/yyyy/ss', 'February/5/1980/00'); + expectFilter(new Date(1955, 1, 5, 0, 0, 3), 'yyyy/MMMM/d ss', '1955/February/5 03'); + expectFilter(new Date(2013, 7, 11, 0, 0, 46), 'd-MM-yy ss', '11-08-13 46'); + expectFilter(new Date(2015, 2, 22, 22, 33, 44), 'd.MMMM.yy.HH:mm:ss', '22.March.15.22:33:44'); + expectFilter(new Date(2015, 2, 22, 0, 0, 1), 'd.MMMM.yy.H:m:ss', '22.March.15.0:0:01'); + }); + + it('should work correctly for `s`', function() { + expectFilter(new Date(2015, 2, 22, 0, 0, 22), 'd.MMMM.yy.s', '22.March.15.22'); + expectFilter(new Date(1991, 2, 8, 0, 0, 59), 'd-MMMM-yyyy-s', '8-March-1991-59'); + expectFilter(new Date(1980, 1, 5, 0, 0, 0), 'MMMM/d/yyyy/s', 'February/5/1980/0'); + expectFilter(new Date(1955, 1, 5, 0, 0, 3), 'yyyy/MMMM/d s', '1955/February/5 3'); + expectFilter(new Date(2013, 7, 11, 0, 0, 46), 'd-MM-yy s', '11-08-13 46'); + expectFilter(new Date(2015, 2, 22, 22, 33, 4), 'd.MMMM.yy.HH:mm:s', '22.March.15.22:33:4'); + expectFilter(new Date(2015, 2, 22, 22, 3, 4), 'd.MMMM.yy.HH:m:s', '22.March.15.22:3:4'); + }); + + it('should work correctly for `a`', function() { + expectFilter(new Date(2015, 2, 22, 10), 'd.MMMM.yy.hha', '22.March.15.10AM'); + expectFilter(new Date(2015, 2, 22, 22), 'd.MMMM.yy.hha', '22.March.15.10PM'); + expectFilter(new Date(1991, 2, 8, 11), 'd-MMMM-yyyy-hha', '8-March-1991-11AM'); + expectFilter(new Date(1991, 2, 8, 23), 'd-MMMM-yyyy-hha', '8-March-1991-11PM'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/hha', 'February/5/1980/12AM'); + expectFilter(new Date(1980, 1, 5, 12), 'MMMM/d/yyyy/hha', 'February/5/1980/12PM'); + expectFilter(new Date(1955, 1, 5, 3), 'yyyy/MMMM/d hha', '1955/February/5 03AM'); + expectFilter(new Date(1955, 1, 5, 15), 'yyyy/MMMM/d hha', '1955/February/5 03PM'); + expectFilter(new Date(2013, 7, 11, 9), 'd-MM-yy hha', '11-08-13 09AM'); + expectFilter(new Date(2013, 7, 11, 21), 'd-MM-yy hha', '11-08-13 09PM'); + }); + + it('should work correctly for `ww`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'd.MMMM.yy.ww', '17.November.13.47'); + expectFilter(new Date(1991, 2, 8, 0), 'd-MMMM-yyyy-ww', '8-March-1991-10'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/ww', 'February/5/1980/06'); + expectFilter(new Date(1955, 1, 5, 0), 'yyyy/MMMM/d/ww', '1955/February/5/05'); + expectFilter(new Date(2013, 7, 11, 0), 'd-MM-yy ww', '11-08-13 33'); + expectFilter(oldDate, 'yyyy/MM/d ww', '0001/03/6 10'); + }); + + it('should work correctly for `w`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'd.MMMM.yy.w', '17.November.13.47'); + expectFilter(new Date(1991, 2, 8, 0), 'd-MMMM-yyyy-w', '8-March-1991-10'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/w', 'February/5/1980/6'); + expectFilter(new Date(1955, 1, 5, 0), 'yyyy/MMMM/d/w', '1955/February/5/5'); + expectFilter(new Date(2013, 7, 11, 0), 'd-MM-yy w', '11-08-13 33'); + expectFilter(oldDate, 'yyyy/MM/d w', '0001/03/6 10'); + }); + + it('should work correctly for `G`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'd.MMMM.yy.G', '17.November.13.AD'); + expectFilter(new Date(-1991, 2, 8, 0), 'd-MMMM-yyyy-G', '8-March-1991-BC'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/G', 'February/5/1980/AD'); + expectFilter(new Date(-1955, 1, 5, 0), 'yyyy/MMMM/d/G', '1955/February/5/BC'); + expectFilter(new Date(2013, 7, 11, 0), 'd-MM-yy G', '11-08-13 AD'); + }); + + it('should work correctly for `GG`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'd.MMMM.yy.GG', '17.November.13.AD'); + expectFilter(new Date(-1991, 2, 8, 0), 'd-MMMM-yyyy-GG', '8-March-1991-BC'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/GG', 'February/5/1980/AD'); + expectFilter(new Date(-1955, 1, 5, 0), 'yyyy/MMMM/d/GG', '1955/February/5/BC'); + expectFilter(new Date(2013, 7, 11, 0), 'd-MM-yy GG', '11-08-13 AD'); + }); + + it('should work correctly for `GGG`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'd.MMMM.yy.GGG', '17.November.13.AD'); + expectFilter(new Date(-1991, 2, 8, 0), 'd-MMMM-yyyy-GGG', '8-March-1991-BC'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/GGG', 'February/5/1980/AD'); + expectFilter(new Date(-1955, 1, 5, 0), 'yyyy/MMMM/d/GGG', '1955/February/5/BC'); + expectFilter(new Date(2013, 7, 11, 0), 'd-MM-yy GGG', '11-08-13 AD'); + }); + + it('should work correctly for `GGGG`', function() { + expectFilter(new Date(2013, 10, 17, 0), 'd.MMMM.yy.GGGG', '17.November.13.Anno Domini'); + expectFilter(new Date(-1991, 2, 8, 0), 'd-MMMM-yyyy-GGGG', '8-March-1991-Before Christ'); + expectFilter(new Date(1980, 1, 5, 0), 'MMMM/d/yyyy/GGGG', 'February/5/1980/Anno Domini'); + expectFilter(new Date(-1955, 1, 5, 0), 'yyyy/MMMM/d/GGGG', '1955/February/5/Before Christ'); + expectFilter(new Date(2013, 7, 11, 0), 'd-MM-yy GGGG', '11-08-13 Anno Domini'); + }); + + it('should work correctly for literal text', function() { + expectFilter(new Date(2013, 10, 17, 0), 'dd.MM.yyyy foo', '17.11.2013 foo'); + }); + }); + + describe('with custom formats', function() { + it('should work correctly for `dd`, `MM`, `yyyy`', function() { + expectParse('17.11.2013', 'dd.MM.yyyy', new Date(2013, 10, 17, 0)); + expectParse('31.12.2013', 'dd.MM.yyyy', new Date(2013, 11, 31, 0)); + expectParse('08-03-1991', 'dd-MM-yyyy', new Date(1991, 2, 8, 0)); + expectParse('03/05/1980', 'MM/dd/yyyy', new Date(1980, 2, 5, 0)); + expectParse('10.01/1983', 'dd.MM/yyyy', new Date(1983, 0, 10, 0)); + expectParse('11-09-1980', 'MM-dd-yyyy', new Date(1980, 10, 9, 0)); + expectParse('2011/02/05', 'yyyy/MM/dd', new Date(2011, 1, 5, 0)); + expectParse('0001/03/06', 'yyyy/MM/dd', oldDate); + }); + + it('should work correctly for `yy`', function() { + expectParse('17.11.13', 'dd.MM.yy', new Date(2013, 10, 17, 0)); + expectParse('02-05-11', 'dd-MM-yy', new Date(2011, 4, 2, 0)); + expectParse('02/05/80', 'MM/dd/yy', new Date(1980, 1, 5, 0)); + expectParse('55/02/05', 'yy/MM/dd', new Date(2055, 1, 5, 0)); + expectParse('11-08-13', 'dd-MM-yy', new Date(2013, 7, 11, 0)); + }); + + it('should use `68` as the pivot year for `yy`', function() { + expectParse('17.11.68', 'dd.MM.yy', new Date(2068, 10, 17, 0)); + expectParse('17.11.69', 'dd.MM.yy', new Date(1969, 10, 17, 0)); + }); + + it('should work correctly for `y`', function() { + expectParse('17.11.2013', 'dd.MM.y', new Date(2013, 10, 17, 0)); + expectParse('31.12.2013', 'dd.MM.y', new Date(2013, 11, 31, 0)); + expectParse('08-03-1991', 'dd-MM-y', new Date(1991, 2, 8, 0)); + expectParse('03/05/1980', 'MM/dd/y', new Date(1980, 2, 5, 0)); + expectParse('10.01/1983', 'dd.MM/y', new Date(1983, 0, 10, 0)); + expectParse('11-09-1980', 'MM-dd-y', new Date(1980, 10, 9, 0)); + expectParse('2011/02/05', 'y/MM/dd', new Date(2011, 1, 5, 0)); + }); + + it('should work correctly for `MMMM`', function() { + expectParse('17.November.13', 'dd.MMMM.yy', new Date(2013, 10, 17, 0)); + expectParse('05-March-1980', 'dd-MMMM-yyyy', new Date(1980, 2, 5, 0)); + expectParse('February/05/1980', 'MMMM/dd/yyyy', new Date(1980, 1, 5, 0)); + expectParse('1949/December/20', 'yyyy/MMMM/dd', new Date(1949, 11, 20, 0)); + expectParse('0001/March/06', 'yyyy/MMMM/dd', oldDate); + }); + + it('should work correctly for `MMM`', function() { + expectParse('30.Sep.10', 'dd.MMM.yy', new Date(2010, 8, 30, 0)); + expectParse('02-May-11', 'dd-MMM-yy', new Date(2011, 4, 2, 0)); + expectParse('Feb/05/1980', 'MMM/dd/yyyy', new Date(1980, 1, 5, 0)); + expectParse('1955/Feb/05', 'yyyy/MMM/dd', new Date(1955, 1, 5, 0)); + expectParse('0001/Mar/06', 'yyyy/MMM/dd', oldDate); + }); + + it('should work correctly for `M`', function() { + expectParse('8/11/2013', 'M/dd/yyyy', new Date(2013, 7, 11, 0)); + expectParse('07.11.05', 'dd.M.yy', new Date(2005, 10, 7, 0)); + expectParse('02-5-11', 'dd-M-yy', new Date(2011, 4, 2, 0)); + expectParse('2/05/1980', 'M/dd/yyyy', new Date(1980, 1, 5, 0)); + expectParse('1955/2/05', 'yyyy/M/dd', new Date(1955, 1, 5, 0)); + expectParse('02-5-11', 'dd-M-yy', new Date(2011, 4, 2, 0)); + }); + + it('should work correctly for `M!`', function() { + expectParse('8/11/2013', 'M!/dd/yyyy', new Date(2013, 7, 11, 0)); + expectParse('07.11.05', 'dd.M!.yy', new Date(2005, 10, 7, 0)); + expectParse('02-5-11', 'dd-M!-yy', new Date(2011, 4, 2, 0)); + expectParse('2/05/1980', 'M!/dd/yyyy', new Date(1980, 1, 5, 0)); + expectParse('1955/2/05', 'yyyy/M!/dd', new Date(1955, 1, 5, 0)); + expectParse('02-5-11', 'dd-M!-yy', new Date(2011, 4, 2, 0)); + expectParse('0001/3/06', 'yyyy/M!/dd', oldDate); + + expectParse('08/11/2013', 'M!/dd/yyyy', new Date(2013, 7, 11, 0)); + expectParse('07.11.05', 'dd.M!.yy', new Date(2005, 10, 7, 0)); + expectParse('02-05-11', 'dd-M!-yy', new Date(2011, 4, 2, 0)); + expectParse('02/05/1980', 'M!/dd/yyyy', new Date(1980, 1, 5, 0)); + expectParse('1955/02/05', 'yyyy/M!/dd', new Date(1955, 1, 5, 0)); + expectParse('02-05-11', 'dd-M!-yy', new Date(2011, 4, 2, 0)); + expectParse('0001/03/06', 'yyyy/M!/dd', oldDate); + }); + + it('should work correctly for `d`', function() { + expectParse('17.November.13', 'd.MMMM.yy', new Date(2013, 10, 17, 0)); + expectParse('8-March-1991', 'd-MMMM-yyyy', new Date(1991, 2, 8, 0)); + expectParse('February/5/1980', 'MMMM/d/yyyy', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5', 'yyyy/MMMM/d', new Date(1955, 1, 5, 0)); + expectParse('11-08-13', 'd-MM-yy', new Date(2013, 7, 11, 0)); + expectParse('0001/03/6', 'yyyy/MM/d', oldDate); + }); + + it('should work correctly for `d!`', function() { + expectParse('17.November.13', 'd!.MMMM.yy', new Date(2013, 10, 17, 0)); + expectParse('8-March-1991', 'd!-MMMM-yyyy', new Date(1991, 2, 8, 0)); + expectParse('February/5/1980', 'MMMM/d!/yyyy', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5', 'yyyy/MMMM/d!', new Date(1955, 1, 5, 0)); + expectParse('11-08-13', 'd!-MM-yy', new Date(2013, 7, 11, 0)); + expectParse('0001/03/6', 'yyyy/MM/d!', oldDate); + + expectParse('17.November.13', 'd!.MMMM.yy', new Date(2013, 10, 17, 0)); + expectParse('08-March-1991', 'd!-MMMM-yyyy', new Date(1991, 2, 8, 0)); + expectParse('February/05/1980', 'MMMM/d!/yyyy', new Date(1980, 1, 5, 0)); + expectParse('1955/February/05', 'yyyy/MMMM/d!', new Date(1955, 1, 5, 0)); + expectParse('11-08-13', 'd!-MM-yy', new Date(2013, 7, 11, 0)); + expectParse('0001/03/06', 'yyyy/MM/d!', oldDate); + }); + + it('should work correctly for `EEEE`', function() { + expectParse('Sunday.17.November.13', 'EEEE.d.MMMM.yy', new Date(2013, 10, 17, 0)); + expectParse('8-Friday-March-1991', 'd-EEEE-MMMM-yyyy', new Date(1991, 2, 8, 0)); + expectParse('February/5/1980/Tuesday', 'MMMM/d/yyyy/EEEE', new Date(1980, 1, 5, 0)); + expectParse('1955/Saturday/February/5', 'yyyy/EEEE/MMMM/d', new Date(1955, 1, 5, 0)); + }); + + it('should work correctly for `EEE`', function() { + expectParse('Sun.17.November.13', 'EEE.d.MMMM.yy', new Date(2013, 10, 17, 0)); + expectParse('8-Fri-March-1991', 'd-EEE-MMMM-yyyy', new Date(1991, 2, 8, 0)); + expectParse('February/5/1980/Tue', 'MMMM/d/yyyy/EEE', new Date(1980, 1, 5, 0)); + expectParse('1955/Sat/February/5', 'yyyy/EEE/MMMM/d', new Date(1955, 1, 5, 0)); + }); + + it('should work correctly for `HH`', function() { + expectParse('22.March.15.22', 'd.MMMM.yy.HH', new Date(2015, 2, 22, 22)); + expectParse('8-March-1991-11', 'd-MMMM-yyyy-HH', new Date(1991, 2, 8, 11)); + expectParse('February/5/1980/00', 'MMMM/d/yyyy/HH', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5 03', 'yyyy/MMMM/d HH', new Date(1955, 1, 5, 3)); + expectParse('11-08-13 23', 'd-MM-yy HH', new Date(2013, 7, 11, 23)); + }); + + it('should work correctly for `H`', function() { + expectParse('22.March.15.22', 'd.MMMM.yy.H', new Date(2015, 2, 22, 22)); + expectParse('8-March-1991-11', 'd-MMMM-yyyy-H', new Date(1991, 2, 8, 11)); + expectParse('February/5/1980/0', 'MMMM/d/yyyy/H', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5 3', 'yyyy/MMMM/d H', new Date(1955, 1, 5, 3)); + expectParse('11-08-13 23', 'd-MM-yy H', new Date(2013, 7, 11, 23)); + }); + + it('should work correctly for `hh`', function() { + expectParse('22.March.15.22', 'd.MMMM.yy.hh', undefined); + expectParse('22.March.15.12', 'd.MMMM.yy.hh', new Date(2015, 2, 22, 12)); + expectParse('8-March-1991-11', 'd-MMMM-yyyy-hh', new Date(1991, 2, 8, 11)); + expectParse('February/5/1980/00', 'MMMM/d/yyyy/hh', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5 03', 'yyyy/MMMM/d hh', new Date(1955, 1, 5, 3)); + expectParse('11-08-13 23', 'd-MM-yy hh', undefined); + expectParse('11-08-13 09', 'd-MM-yy hh', new Date(2013, 7, 11, 9)); + }); + + it('should work correctly for `h`', function() { + expectParse('22.March.15.12', 'd.MMMM.yy.h', new Date(2015, 2, 22, 12)); + expectParse('8-March-1991-11', 'd-MMMM-yyyy-h', new Date(1991, 2, 8, 11)); + expectParse('February/5/1980/0', 'MMMM/d/yyyy/h', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5 3', 'yyyy/MMMM/d h', new Date(1955, 1, 5, 3)); + expectParse('11-08-13 3', 'd-MM-yy h', new Date(2013, 7, 11, 3)); + }); + + it('should work correctly for `mm`', function() { + expectParse('22.March.15.22', 'd.MMMM.yy.mm', new Date(2015, 2, 22, 0, 22)); + expectParse('8-March-1991-59', 'd-MMMM-yyyy-mm', new Date(1991, 2, 8, 0, 59)); + expectParse('February/5/1980/00', 'MMMM/d/yyyy/mm', new Date(1980, 1, 5, 0, 0)); + expectParse('1955/February/5 03', 'yyyy/MMMM/d mm', new Date(1955, 1, 5, 0, 3)); + expectParse('11-08-13 46', 'd-MM-yy mm', new Date(2013, 7, 11, 0, 46)); + expectParse('22.March.15.22:33', 'd.MMMM.yy.HH:mm', new Date(2015, 2, 22, 22, 33)); + expectParse('22.March.15.2:01', 'd.MMMM.yy.H:mm', new Date(2015, 2, 22, 2, 1)); + }); + + it('should work correctly for `m`', function() { + expectParse('22.March.15.22', 'd.MMMM.yy.m', new Date(2015, 2, 22, 0, 22)); + expectParse('8-March-1991-59', 'd-MMMM-yyyy-m', new Date(1991, 2, 8, 0, 59)); + expectParse('February/5/1980/0', 'MMMM/d/yyyy/m', new Date(1980, 1, 5, 0, 0)); + expectParse('1955/February/5 3', 'yyyy/MMMM/d m', new Date(1955, 1, 5, 0, 3)); + expectParse('11-08-13 46', 'd-MM-yy m', new Date(2013, 7, 11, 0, 46)); + expectParse('22.March.15.22:3', 'd.MMMM.yy.HH:m', new Date(2015, 2, 22, 22, 3)); + expectParse('22.March.15.2:1', 'd.MMMM.yy.H:m', new Date(2015, 2, 22, 2, 1)); + }); + + it('should work correctly for `sss`', function() { + expectParse('22.March.15.123', 'd.MMMM.yy.sss', new Date(2015, 2, 22, 0, 0, 0, 123)); + expectParse('8-March-1991-059', 'd-MMMM-yyyy-sss', new Date(1991, 2, 8, 0, 0, 0, 59)); + expectParse('February/5/1980/000', 'MMMM/d/yyyy/sss', new Date(1980, 1, 5, 0, 0, 0)); + expectParse('1955/February/5 003', 'yyyy/MMMM/d sss', new Date(1955, 1, 5, 0, 0, 0, 3)); + expectParse('11-08-13 046', 'd-MM-yy sss', new Date(2013, 7, 11, 0, 0, 0, 46)); + expectParse('22.March.15.22:33:044', 'd.MMMM.yy.HH:mm:sss', new Date(2015, 2, 22, 22, 33, 0, 44)); + expectParse('22.March.15.0:0:001', 'd.MMMM.yy.H:m:sss', new Date(2015, 2, 22, 0, 0, 0, 1)); + }); + + it('should work correctly for `ss`', function() { + expectParse('22.March.15.22', 'd.MMMM.yy.ss', new Date(2015, 2, 22, 0, 0, 22)); + expectParse('8-March-1991-59', 'd-MMMM-yyyy-ss', new Date(1991, 2, 8, 0, 0, 59)); + expectParse('February/5/1980/00', 'MMMM/d/yyyy/ss', new Date(1980, 1, 5, 0, 0, 0)); + expectParse('1955/February/5 03', 'yyyy/MMMM/d ss', new Date(1955, 1, 5, 0, 0, 3)); + expectParse('11-08-13 46', 'd-MM-yy ss', new Date(2013, 7, 11, 0, 0, 46)); + expectParse('22.March.15.22:33:44', 'd.MMMM.yy.HH:mm:ss', new Date(2015, 2, 22, 22, 33, 44)); + expectParse('22.March.15.0:0:01', 'd.MMMM.yy.H:m:ss', new Date(2015, 2, 22, 0, 0, 1)); + }); + + it('should work correctly for `s`', function() { + expectParse('22.March.15.22', 'd.MMMM.yy.s', new Date(2015, 2, 22, 0, 0, 22)); + expectParse('8-March-1991-59', 'd-MMMM-yyyy-s', new Date(1991, 2, 8, 0, 0, 59)); + expectParse('February/5/1980/0', 'MMMM/d/yyyy/s', new Date(1980, 1, 5, 0, 0, 0)); + expectParse('1955/February/5 3', 'yyyy/MMMM/d s', new Date(1955, 1, 5, 0, 0, 3)); + expectParse('11-08-13 46', 'd-MM-yy s', new Date(2013, 7, 11, 0, 0, 46)); + expectParse('22.March.15.22:33:4', 'd.MMMM.yy.HH:mm:s', new Date(2015, 2, 22, 22, 33, 4)); + expectParse('22.March.15.22:3:4', 'd.MMMM.yy.HH:m:s', new Date(2015, 2, 22, 22, 3, 4)); + }); + + it('should work correctly for `a`', function() { + expectParse('22.March.15.10AM', 'd.MMMM.yy.hha', new Date(2015, 2, 22, 10)); + expectParse('22.March.15.10PM', 'd.MMMM.yy.hha', new Date(2015, 2, 22, 22)); + expectParse('8-March-1991-11AM', 'd-MMMM-yyyy-hha', new Date(1991, 2, 8, 11)); + expectParse('8-March-1991-11PM', 'd-MMMM-yyyy-hha', new Date(1991, 2, 8, 23)); + expectParse('February/5/1980/12AM', 'MMMM/d/yyyy/hha', new Date(1980, 1, 5, 0)); + expectParse('February/5/1980/12PM', 'MMMM/d/yyyy/hha', new Date(1980, 1, 5, 12)); + expectParse('1955/February/5 03AM', 'yyyy/MMMM/d hha', new Date(1955, 1, 5, 3)); + expectParse('1955/February/5 03PM', 'yyyy/MMMM/d hha', new Date(1955, 1, 5, 15)); + expectParse('11-08-13 09AM', 'd-MM-yy hha', new Date(2013, 7, 11, 9)); + expectParse('11-08-13 09PM', 'd-MM-yy hha', new Date(2013, 7, 11, 21)); + }); + + it('should work correctly for `Z`', function() { + expectParse('22.March.15 -0700', 'd.MMMM.yy Z', new Date(2015, 2, 21, 17, 0, 0)); + expectParse('8-March-1991 +0800', 'd-MMMM-yyyy Z', new Date(1991, 2, 8, 8, 0, 0)); + expectParse('February/5/1980 -0200', 'MMMM/d/yyyy Z', new Date(1980, 1, 4, 22, 0, 0)); + expectParse('1955/February/5 +0400', 'yyyy/MMMM/d Z', new Date(1955, 1, 5, 4, 0, 0)); + expectParse('11-08-13 -1234', 'd-MM-yy Z', new Date(2013, 7, 10, 11, 26, 0)); + expectParse('22.March.15.22:33:4 -1200', 'd.MMMM.yy.HH:mm:s Z', new Date(2015, 2, 22, 10, 33, 4)); + expectParse('22.March.15.22:3:4 +1500', 'd.MMMM.yy.HH:m:s Z', new Date(2015, 2, 23, 13, 3, 4)); + }); + + it('should work correctly for `ww`', function() { + expectParse('17.November.13.45', 'd.MMMM.yy.ww', new Date(2013, 10, 17, 0)); + expectParse('8-March-1991-09', 'd-MMMM-yyyy-ww', new Date(1991, 2, 8, 0)); + expectParse('February/5/1980/05', 'MMMM/d/yyyy/ww', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5/04', 'yyyy/MMMM/d/ww', new Date(1955, 1, 5, 0)); + expectParse('11-08-13 44', 'd-MM-yy ww', new Date(2013, 7, 11, 0)); + expectParse('0001/03/6 10', 'yyyy/MM/d ww', oldDate); + }); + + it('should work correctly for `w`', function() { + expectParse('17.November.13.45', 'd.MMMM.yy.w', new Date(2013, 10, 17, 0)); + expectParse('8-March-1991-9', 'd-MMMM-yyyy-w', new Date(1991, 2, 8, 0)); + expectParse('February/5/1980/5', 'MMMM/d/yyyy/w', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5/4', 'yyyy/MMMM/d/w', new Date(1955, 1, 5, 0)); + expectParse('11-08-13 44', 'd-MM-yy w', new Date(2013, 7, 11, 0)); + expectParse('0001/03/6 10', 'yyyy/MM/d w', oldDate); + }); + + it('should work correctly for `G`', function() { + expectParse('17.November.13.AD', 'd.MMMM.yy.G', new Date(2013, 10, 17, 0)); + expectParse('8-March-1991-BC', 'd-MMMM-yyyy-G', new Date(1991, 2, 8, 0)); + expectParse('February/5/1980/AD', 'MMMM/d/yyyy/G', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5/BC', 'yyyy/MMMM/d/G', new Date(1955, 1, 5, 0)); + expectParse('11-08-13 AD', 'd-MM-yy G', new Date(2013, 7, 11, 0)); + expectParse('0001/03/6 BC', 'yyyy/MM/d G', oldDate); + }); + + it('should work correctly for `GG`', function() { + expectParse('17.November.13.AD', 'd.MMMM.yy.GG', new Date(2013, 10, 17, 0)); + expectParse('8-March-1991-BC', 'd-MMMM-yyyy-GG', new Date(1991, 2, 8, 0)); + expectParse('February/5/1980/AD', 'MMMM/d/yyyy/GG', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5/BC', 'yyyy/MMMM/d/GG', new Date(1955, 1, 5, 0)); + expectParse('11-08-13 AD', 'd-MM-yy GG', new Date(2013, 7, 11, 0)); + expectParse('0001/03/6 BC', 'yyyy/MM/d GG', oldDate); + }); + + it('should work correctly for `GGG`', function() { + expectParse('17.November.13.AD', 'd.MMMM.yy.GGG', new Date(2013, 10, 17, 0)); + expectParse('8-March-1991-BC', 'd-MMMM-yyyy-GGG', new Date(1991, 2, 8, 0)); + expectParse('February/5/1980/AD', 'MMMM/d/yyyy/GGG', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5/BC', 'yyyy/MMMM/d/GGG', new Date(1955, 1, 5, 0)); + expectParse('11-08-13 AD', 'd-MM-yy GGG', new Date(2013, 7, 11, 0)); + expectParse('0001/03/6 BC', 'yyyy/MM/d GGG', oldDate); + }); + + it('should work correctly for `GGGG`', function() { + expectParse('17.November.13.Anno Domini', 'd.MMMM.yy.GGGG', new Date(2013, 10, 17, 0)); + expectParse('8-March-1991-Before Christ', 'd-MMMM-yyyy-GGGG', new Date(1991, 2, 8, 0)); + expectParse('February/5/1980/Anno Domini', 'MMMM/d/yyyy/GGGG', new Date(1980, 1, 5, 0)); + expectParse('1955/February/5/Before Christ', 'yyyy/MMMM/d/GGGG', new Date(1955, 1, 5, 0)); + expectParse('11-08-13 Anno Domini', 'd-MM-yy GGGG', new Date(2013, 7, 11, 0)); + expectParse('0001/03/6 Before Christ', 'yyyy/MM/d GGGG', oldDate); + }); + }); + + describe('with predefined formats', function() { + it('should work correctly for `shortDate`', function() { + expectParse('9/3/10', 'shortDate', new Date(2010, 8, 3, 0)); + }); + + it('should work correctly for `mediumDate`', function() { + expectParse('Sep 3, 2010', 'mediumDate', new Date(2010, 8, 3, 0)); + }); + + it('should work correctly for `longDate`', function() { + expectParse('September 3, 2010', 'longDate', new Date(2010, 8, 3, 0)); + }); + + it('should work correctly for `fullDate`', function() { + expectParse('Friday, September 3, 2010', 'fullDate', new Date(2010, 8, 3, 0)); + }); + }); + + describe('with value literals', function() { + describe('filter', function() { + it('should work with multiple literals', function() { + expect(dateParser.filter(new Date(2013, 0, 29), 'd \'de\' MMMM \'de\' y')).toEqual('29 de January de 2013'); + }); + + it('should work with escaped single quote', function() { + expect(dateParser.filter(new Date(2015, 2, 22, 12), 'd.MMMM.yy h \'o\'\'clock\'')).toEqual('22.March.15 12 o\'clock'); + }); + + it('should work with only a single quote', function() { + expect(dateParser.filter(new Date(2015, 2, 22), 'd.MMMM.yy \'\'\'')).toEqual('22.March.15 \''); + }); + + it('should work with trailing literal', function() { + expect(dateParser.filter(new Date(2013, 0, 1), '\'year\' y')).toEqual('year 2013'); + }); + + it('should work without whitespace', function() { + expect(dateParser.filter(new Date(2013, 0, 1), '\'year:\'y')).toEqual('year:2013'); + }); + }); + + describe('parse', function() { + it('should work with multiple literals', function() { + expect(dateParser.parse('29 de January de 2013', 'd \'de\' MMMM \'de\' y')).toEqual(new Date(2013, 0, 29)); + }); + + it('should work with escaped single quote', function() { + expect(dateParser.parse('22.March.15 12 o\'clock', 'd.MMMM.yy h \'o\'\'clock\'')).toEqual(new Date(2015, 2, 22, 12)); + }); + + it('should work with only a single quote', function() { + expect(dateParser.parse('22.March.15 \'', 'd.MMMM.yy \'\'\'')).toEqual(new Date(2015, 2, 22)); + }); + + it('should work with trailing literal', function() { + expect(dateParser.parse('year 2013', '\'year\' y')).toEqual(new Date(2013, 0, 1)); + }); + + it('should work without whitespace', function() { + expect(dateParser.parse('year:2013', '\'year:\'y')).toEqual(new Date(2013, 0, 1)); + }); + }); + }); + + describe('with edge case', function() { + it('should not work for invalid number of days in February', function() { + expectParse('29.02.2013', 'dd.MM.yyyy', undefined); + }); + + it('should not work for 0 number of days', function() { + expectParse('00.02.2013', 'dd.MM.yyyy', undefined); + }); + + it('should work for 29 days in February for leap years', function() { + expectParse('29.02.2000', 'dd.MM.yyyy', new Date(2000, 1, 29, 0)); + }); + + it('should not work for 31 days for some months', function() { + expectParse('31-04-2013', 'dd-MM-yyyy', undefined); + expectParse('November 31, 2013', 'MMMM d, yyyy', undefined); + }); + }); + + describe('base date', function() { + var baseDate; + + beforeEach(function() { + baseDate = new Date(2010, 10, 10); + }); + + it('should pre-initialize our date with a base date', function() { + expect(expectBaseParse('2015', 'yyyy', baseDate, new Date(2015, 10, 10))); + expect(expectBaseParse('1', 'M', baseDate, new Date(2010, 0, 10))); + expect(expectBaseParse('1', 'd', baseDate, new Date(2010, 10, 1))); + }); + + it('should ignore the base date when it is an invalid date', inject(function($log) { + spyOn($log, 'warn'); + expect(expectBaseParse('30-12', 'dd-MM', new Date('foo'), new Date(1900, 11, 30))); + expect(expectBaseParse('30-2015', 'dd-yyyy', 'I am a cat', new Date(2015, 0, 30))); + expect($log.warn).toHaveBeenCalledWith('dateparser:', 'baseDate is not a valid date'); + })); + }); + + it('should not parse non-string inputs', function() { + expectParse(123456, 'dd.MM.yyyy', 123456); + var date = new Date(); + expectParse(date, 'dd.MM.yyyy', date); + }); + + it('should not parse if no format is specified', function() { + expectParse('21.08.1951', '', '21.08.1951'); + }); + + it('should reinitialize when locale changes', inject(function($locale) { + spyOn(dateParser, 'init').and.callThrough(); + expect($locale.id).toBe('en-us'); + + $locale.id = 'en-uk'; + + dateParser.parse('22.March.15.22', 'd.MMMM.yy.s'); + + expect(dateParser.init).toHaveBeenCalled(); + })); + + describe('timezone functions', function() { + describe('toTimezone', function() { + it('adjusts date: PST - EST', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var toWestDate = dateParser.toTimezone(date, 'PST'); + var toEastDate = dateParser.toTimezone(date, 'EST'); + expect(toWestDate.getTime() - toEastDate.getTime()).toEqual(1000 * 60 * 60 * 3); + }); + + it('adjusts date: GMT-0500 - GMT+0500', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var toWestDate = dateParser.toTimezone(date, 'GMT-0500'); + var toEastDate = dateParser.toTimezone(date, 'GMT+0500'); + expect(toWestDate.getTime() - toEastDate.getTime()).toEqual(1000 * 60 * 60 * 10); + }); + + it('adjusts date: -600 - +600', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var toWestDate = dateParser.toTimezone(date, '-600'); + var toEastDate = dateParser.toTimezone(date, '+600'); + expect(toWestDate.getTime() - toEastDate.getTime()).toEqual(1000 * 60 * 60 * 12); + }); + + it('tolerates null date', function() { + var date = null; + var toNullDate = dateParser.toTimezone(date, '-600'); + expect(toNullDate).toEqual(date); + }); + + it('tolerates null timezone', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var toNullTimezoneDate = dateParser.toTimezone(date, null); + expect(toNullTimezoneDate).toEqual(date); + }); + }); + + describe('fromTimezone', function() { + it('adjusts date: PST - EST', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var fromWestDate = dateParser.fromTimezone(date, 'PST'); + var fromEastDate = dateParser.fromTimezone(date, 'EST'); + expect(fromWestDate.getTime() - fromEastDate.getTime()).toEqual(1000 * 60 * 60 * -3); + }); + + it('adjusts date: GMT-0500 - GMT+0500', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var fromWestDate = dateParser.fromTimezone(date, 'GMT-0500'); + var fromEastDate = dateParser.fromTimezone(date, 'GMT+0500'); + expect(fromWestDate.getTime() - fromEastDate.getTime()).toEqual(1000 * 60 * 60 * -10); + }); + + it('adjusts date: -600 - +600', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var fromWestDate = dateParser.fromTimezone(date, '-600'); + var fromEastDate = dateParser.fromTimezone(date, '+600'); + expect(fromWestDate.getTime() - fromEastDate.getTime()).toEqual(1000 * 60 * 60 * -12); + }); + + it('tolerates null date', function() { + var date = null; + var toNullDate = dateParser.fromTimezone(date, '-600'); + expect(toNullDate).toEqual(date); + }); + + it('tolerates null timezone', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var toNullTimezoneDate = dateParser.fromTimezone(date, null); + expect(toNullTimezoneDate).toEqual(date); + }); + }); + + describe('timezoneToOffset', function() { + it('calculates minutes off from current timezone', function() { + var offsetMinutesUtc = dateParser.timezoneToOffset('UTC'); + var offsetMinutesUtcPlus1 = dateParser.timezoneToOffset('GMT+0100'); + expect(offsetMinutesUtc - offsetMinutesUtcPlus1).toEqual(60); + }); + }); + + describe('addDateMinutes', function() { + it('adds minutes to a date', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var oneHourMore = dateParser.addDateMinutes(date, 60); + expect(oneHourMore).toEqual(new Date('2008-01-01T01:00:00.000Z')); + }); + }); + + describe('convertTimezoneToLocal', function() { + it('adjusts date: PST - EST', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var toWestDate = dateParser.convertTimezoneToLocal(date, 'PST'); + var toEastDate = dateParser.convertTimezoneToLocal(date, 'EST'); + expect(toWestDate.getTime() - toEastDate.getTime()).toEqual(1000 * 60 * 60 * 3); + }); + + it('adjusts date: GMT-0500 - GMT+0500', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var toWestDate = dateParser.convertTimezoneToLocal(date, 'GMT-0500'); + var toEastDate = dateParser.convertTimezoneToLocal(date, 'GMT+0500'); + expect(toWestDate.getTime() - toEastDate.getTime()).toEqual(1000 * 60 * 60 * 10); + }); + + it('adjusts date: -600 - +600', function() { + var date = new Date('2008-01-01T00:00:00.000Z'); + var toWestDate = dateParser.convertTimezoneToLocal(date, '-600'); + var toEastDate = dateParser.convertTimezoneToLocal(date, '+600'); + expect(toWestDate.getTime() - toEastDate.getTime()).toEqual(1000 * 60 * 60 * 12); + }); + }); + }); + + describe('overrideParser', function() { + var twoDigitYearParser = function (value) { + this.year = +value + (+value > 30 ? 1900 : 2000); + }; + + it('should get the current parser', function() { + expect(dateParser.getParser('yy')).toBeTruthy(); + }); + + it('should override the parser', function() { + dateParser.overrideParser('yy', twoDigitYearParser); + expect(dateParser.parse('68', 'yy').getFullYear()).toEqual(1968); + expect(dateParser.parse('67', 'yy').getFullYear()).toEqual(1967); + expect(dateParser.parse('31', 'yy').getFullYear()).toEqual(1931); + expect(dateParser.parse('30', 'yy').getFullYear()).toEqual(2030); + }); + + it('should clear cached parsers', function() { + expect(dateParser.parse('68', 'yy').getFullYear()).toEqual(2068); + dateParser.overrideParser('yy', twoDigitYearParser); + expect(dateParser.parse('68', 'yy').getFullYear()).toEqual(1968); + }); + }); +}); diff --git a/src/datepicker/datepicker.css b/src/datepicker/datepicker.css new file mode 100644 index 0000000000..c49e180968 --- /dev/null +++ b/src/datepicker/datepicker.css @@ -0,0 +1,11 @@ +.uib-datepicker .uib-title { + width: 100%; +} + +.uib-day button, .uib-month button, .uib-year button { + min-width: 100%; +} + +.uib-left, .uib-right { + width: 100% +} diff --git a/src/datepicker/datepicker.js b/src/datepicker/datepicker.js index 8c0af8eeb8..fd4e263660 100644 --- a/src/datepicker/datepicker.js +++ b/src/datepicker/datepicker.js @@ -1,437 +1,685 @@ -angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) - -.constant('datepickerConfig', { - dayFormat: 'dd', - monthFormat: 'MMMM', - yearFormat: 'yyyy', - dayHeaderFormat: 'EEE', - dayTitleFormat: 'MMMM yyyy', - monthTitleFormat: 'yyyy', - showWeeks: true, - startingDay: 0, - yearRange: 20, +angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.dateparser', 'ui.bootstrap.isClass']) + +.value('$datepickerSuppressError', false) + +.value('$datepickerLiteralWarning', true) + +.constant('uibDatepickerConfig', { + datepickerMode: 'day', + formatDay: 'dd', + formatMonth: 'MMMM', + formatYear: 'yyyy', + formatDayHeader: 'EEE', + formatDayTitle: 'MMMM yyyy', + formatMonthTitle: 'yyyy', + maxDate: null, + maxMode: 'year', minDate: null, - maxDate: null + minMode: 'day', + monthColumns: 3, + ngModelOptions: {}, + shortcutPropagation: false, + showWeeks: true, + yearColumns: 5, + yearRows: 4 }) -.controller('DatepickerController', ['$scope', '$attrs', 'dateFilter', 'datepickerConfig', function($scope, $attrs, dateFilter, dtConfig) { - var format = { - day: getValue($attrs.dayFormat, dtConfig.dayFormat), - month: getValue($attrs.monthFormat, dtConfig.monthFormat), - year: getValue($attrs.yearFormat, dtConfig.yearFormat), - dayHeader: getValue($attrs.dayHeaderFormat, dtConfig.dayHeaderFormat), - dayTitle: getValue($attrs.dayTitleFormat, dtConfig.dayTitleFormat), - monthTitle: getValue($attrs.monthTitleFormat, dtConfig.monthTitleFormat) - }, - startingDay = getValue($attrs.startingDay, dtConfig.startingDay), - yearRange = getValue($attrs.yearRange, dtConfig.yearRange); - - this.minDate = dtConfig.minDate ? new Date(dtConfig.minDate) : null; - this.maxDate = dtConfig.maxDate ? new Date(dtConfig.maxDate) : null; - - function getValue(value, defaultValue) { - return angular.isDefined(value) ? $scope.$parent.$eval(value) : defaultValue; - } +.controller('UibDatepickerController', ['$scope', '$element', '$attrs', '$parse', '$interpolate', '$locale', '$log', 'dateFilter', 'uibDatepickerConfig', '$datepickerLiteralWarning', '$datepickerSuppressError', 'uibDateParser', + function($scope, $element, $attrs, $parse, $interpolate, $locale, $log, dateFilter, datepickerConfig, $datepickerLiteralWarning, $datepickerSuppressError, dateParser) { + var self = this, + ngModelCtrl = { $setViewValue: angular.noop }, // nullModelCtrl; + ngModelOptions = {}, + watchListeners = []; + + $element.addClass('uib-datepicker'); + $attrs.$set('role', 'application'); - function getDaysInMonth( year, month ) { - return new Date(year, month, 0).getDate(); + if (!$scope.datepickerOptions) { + $scope.datepickerOptions = {}; } - function getDates(startDate, n) { - var dates = new Array(n); - var current = startDate, i = 0; - while (i < n) { - dates[i++] = new Date(current); - current.setDate( current.getDate() + 1 ); + // Modes chain + this.modes = ['day', 'month', 'year']; + + [ + 'customClass', + 'dateDisabled', + 'datepickerMode', + 'formatDay', + 'formatDayHeader', + 'formatDayTitle', + 'formatMonth', + 'formatMonthTitle', + 'formatYear', + 'maxDate', + 'maxMode', + 'minDate', + 'minMode', + 'monthColumns', + 'showWeeks', + 'shortcutPropagation', + 'startingDay', + 'yearColumns', + 'yearRows' + ].forEach(function(key) { + switch (key) { + case 'customClass': + case 'dateDisabled': + $scope[key] = $scope.datepickerOptions[key] || angular.noop; + break; + case 'datepickerMode': + $scope.datepickerMode = angular.isDefined($scope.datepickerOptions.datepickerMode) ? + $scope.datepickerOptions.datepickerMode : datepickerConfig.datepickerMode; + break; + case 'formatDay': + case 'formatDayHeader': + case 'formatDayTitle': + case 'formatMonth': + case 'formatMonthTitle': + case 'formatYear': + self[key] = angular.isDefined($scope.datepickerOptions[key]) ? + $interpolate($scope.datepickerOptions[key])($scope.$parent) : + datepickerConfig[key]; + break; + case 'monthColumns': + case 'showWeeks': + case 'shortcutPropagation': + case 'yearColumns': + case 'yearRows': + self[key] = angular.isDefined($scope.datepickerOptions[key]) ? + $scope.datepickerOptions[key] : datepickerConfig[key]; + break; + case 'startingDay': + if (angular.isDefined($scope.datepickerOptions.startingDay)) { + self.startingDay = $scope.datepickerOptions.startingDay; + } else if (angular.isNumber(datepickerConfig.startingDay)) { + self.startingDay = datepickerConfig.startingDay; + } else { + self.startingDay = ($locale.DATETIME_FORMATS.FIRSTDAYOFWEEK + 8) % 7; + } + + break; + case 'maxDate': + case 'minDate': + $scope.$watch('datepickerOptions.' + key, function(value) { + if (value) { + if (angular.isDate(value)) { + self[key] = dateParser.fromTimezone(new Date(value), ngModelOptions.getOption('timezone')); + } else { + if ($datepickerLiteralWarning) { + $log.warn('Literal date support has been deprecated, please switch to date object usage'); + } + + self[key] = new Date(dateFilter(value, 'medium')); + } + } else { + self[key] = datepickerConfig[key] ? + dateParser.fromTimezone(new Date(datepickerConfig[key]), ngModelOptions.getOption('timezone')) : + null; + } + + self.refreshView(); + }); + + break; + case 'maxMode': + case 'minMode': + if ($scope.datepickerOptions[key]) { + $scope.$watch(function() { return $scope.datepickerOptions[key]; }, function(value) { + self[key] = $scope[key] = angular.isDefined(value) ? value : $scope.datepickerOptions[key]; + if (key === 'minMode' && self.modes.indexOf($scope.datepickerOptions.datepickerMode) < self.modes.indexOf(self[key]) || + key === 'maxMode' && self.modes.indexOf($scope.datepickerOptions.datepickerMode) > self.modes.indexOf(self[key])) { + $scope.datepickerMode = self[key]; + $scope.datepickerOptions.datepickerMode = self[key]; + } + }); + } else { + self[key] = $scope[key] = datepickerConfig[key] || null; + } + + break; } - return dates; - } + }); - function makeDate(date, format, isSelected, isSecondary) { - return { date: date, label: dateFilter(date, format), selected: !!isSelected, secondary: !!isSecondary }; + $scope.uniqueId = 'datepicker-' + $scope.$id + '-' + Math.floor(Math.random() * 10000); + + $scope.disabled = angular.isDefined($attrs.disabled) || false; + if (angular.isDefined($attrs.ngDisabled)) { + watchListeners.push($scope.$parent.$watch($attrs.ngDisabled, function(disabled) { + $scope.disabled = disabled; + self.refreshView(); + })); } - this.modes = [ - { - name: 'day', - getVisibleDates: function(date, selected) { - var year = date.getFullYear(), month = date.getMonth(), firstDayOfMonth = new Date(year, month, 1); - var difference = startingDay - firstDayOfMonth.getDay(), - numDisplayedFromPreviousMonth = (difference > 0) ? 7 - difference : - difference, - firstDate = new Date(firstDayOfMonth), numDates = 0; - - if ( numDisplayedFromPreviousMonth > 0 ) { - firstDate.setDate( - numDisplayedFromPreviousMonth + 1 ); - numDates += numDisplayedFromPreviousMonth; // Previous - } - numDates += getDaysInMonth(year, month + 1); // Current - numDates += (7 - numDates % 7) % 7; // Next + $scope.isActive = function(dateObject) { + if (self.compare(dateObject.date, self.activeDate) === 0) { + $scope.activeDateId = dateObject.uid; + return true; + } + return false; + }; - var days = getDates(firstDate, numDates), labels = new Array(7); - for (var i = 0; i < numDates; i ++) { - var dt = new Date(days[i]); - days[i] = makeDate(dt, format.day, (selected && selected.getDate() === dt.getDate() && selected.getMonth() === dt.getMonth() && selected.getFullYear() === dt.getFullYear()), dt.getMonth() !== month); - } - for (var j = 0; j < 7; j++) { - labels[j] = dateFilter(days[j].date, format.dayHeader); - } - return { objects: days, title: dateFilter(date, format.dayTitle), labels: labels }; - }, - compare: function(date1, date2) { - return (new Date( date1.getFullYear(), date1.getMonth(), date1.getDate() ) - new Date( date2.getFullYear(), date2.getMonth(), date2.getDate() ) ); - }, - split: 7, - step: { months: 1 } - }, - { - name: 'month', - getVisibleDates: function(date, selected) { - var months = new Array(12), year = date.getFullYear(); - for ( var i = 0; i < 12; i++ ) { - var dt = new Date(year, i, 1); - months[i] = makeDate(dt, format.month, (selected && selected.getMonth() === i && selected.getFullYear() === year)); - } - return { objects: months, title: dateFilter(date, format.monthTitle) }; - }, - compare: function(date1, date2) { - return new Date( date1.getFullYear(), date1.getMonth() ) - new Date( date2.getFullYear(), date2.getMonth() ); - }, - split: 3, - step: { years: 1 } - }, - { - name: 'year', - getVisibleDates: function(date, selected) { - var years = new Array(yearRange), year = date.getFullYear(), startYear = parseInt((year - 1) / yearRange, 10) * yearRange + 1; - for ( var i = 0; i < yearRange; i++ ) { - var dt = new Date(startYear + i, 0, 1); - years[i] = makeDate(dt, format.year, (selected && selected.getFullYear() === dt.getFullYear())); + this.init = function(ngModelCtrl_) { + ngModelCtrl = ngModelCtrl_; + ngModelOptions = extractOptions(ngModelCtrl); + + if ($scope.datepickerOptions.initDate) { + self.activeDate = dateParser.fromTimezone($scope.datepickerOptions.initDate, ngModelOptions.getOption('timezone')) || new Date(); + $scope.$watch('datepickerOptions.initDate', function(initDate) { + if (initDate && (ngModelCtrl.$isEmpty(ngModelCtrl.$modelValue) || ngModelCtrl.$invalid)) { + self.activeDate = dateParser.fromTimezone(initDate, ngModelOptions.getOption('timezone')); + self.refreshView(); } - return { objects: years, title: [years[0].label, years[yearRange - 1].label].join(' - ') }; - }, - compare: function(date1, date2) { - return date1.getFullYear() - date2.getFullYear(); - }, - split: 5, - step: { years: yearRange } + }); + } else { + self.activeDate = new Date(); } - ]; - this.isDisabled = function(date, mode) { - var currentMode = this.modes[mode || 0]; - return ((this.minDate && currentMode.compare(date, this.minDate) < 0) || (this.maxDate && currentMode.compare(date, this.maxDate) > 0) || ($scope.dateDisabled && $scope.dateDisabled({date: date, mode: currentMode.name}))); + var date = ngModelCtrl.$modelValue ? new Date(ngModelCtrl.$modelValue) : new Date(); + this.activeDate = !isNaN(date) ? + dateParser.fromTimezone(date, ngModelOptions.getOption('timezone')) : + dateParser.fromTimezone(new Date(), ngModelOptions.getOption('timezone')); + + ngModelCtrl.$render = function() { + self.render(); + }; }; -}]) -.directive( 'datepicker', ['dateFilter', '$parse', 'datepickerConfig', '$log', function (dateFilter, $parse, datepickerConfig, $log) { - return { - restrict: 'EA', - replace: true, - templateUrl: 'template/datepicker/datepicker.html', - scope: { - dateDisabled: '&' - }, - require: ['datepicker', '?^ngModel'], - controller: 'DatepickerController', - link: function(scope, element, attrs, ctrls) { - var datepickerCtrl = ctrls[0], ngModel = ctrls[1]; + this.render = function() { + if (ngModelCtrl.$viewValue) { + var date = new Date(ngModelCtrl.$viewValue), + isValid = !isNaN(date); - if (!ngModel) { - return; // do nothing if no ng-model + if (isValid) { + this.activeDate = dateParser.fromTimezone(date, ngModelOptions.getOption('timezone')); + } else if (!$datepickerSuppressError) { + $log.error('Datepicker directive: "ng-model" value must be a Date object'); } + } + this.refreshView(); + }; - // Configuration parameters - var mode = 0, selected = new Date(), showWeeks = datepickerConfig.showWeeks; - - if (attrs.showWeeks) { - scope.$parent.$watch($parse(attrs.showWeeks), function(value) { - showWeeks = !! value; - updateShowWeekNumbers(); - }); - } else { - updateShowWeekNumbers(); + this.refreshView = function() { + if (this.element) { + $scope.selectedDt = null; + this._refreshView(); + if ($scope.activeDt) { + $scope.activeDateId = $scope.activeDt.uid; } - if (attrs.min) { - scope.$parent.$watch($parse(attrs.min), function(value) { - datepickerCtrl.minDate = value ? new Date(value) : null; - refill(); - }); - } - if (attrs.max) { - scope.$parent.$watch($parse(attrs.max), function(value) { - datepickerCtrl.maxDate = value ? new Date(value) : null; - refill(); - }); - } + var date = ngModelCtrl.$viewValue ? new Date(ngModelCtrl.$viewValue) : null; + date = dateParser.fromTimezone(date, ngModelOptions.getOption('timezone')); + ngModelCtrl.$setValidity('dateDisabled', !date || + this.element && !this.isDisabled(date)); + } + }; - function updateShowWeekNumbers() { - scope.showWeekNumbers = mode === 0 && showWeeks; - } + this.createDateObject = function(date, format) { + var model = ngModelCtrl.$viewValue ? new Date(ngModelCtrl.$viewValue) : null; + model = dateParser.fromTimezone(model, ngModelOptions.getOption('timezone')); + var today = new Date(); + today = dateParser.fromTimezone(today, ngModelOptions.getOption('timezone')); + var time = this.compare(date, today); + var dt = { + date: date, + label: dateParser.filter(date, format), + selected: model && this.compare(date, model) === 0, + disabled: this.isDisabled(date), + past: time < 0, + current: time === 0, + future: time > 0, + customClass: this.customClass(date) || null + }; + + if (model && this.compare(date, model) === 0) { + $scope.selectedDt = dt; + } - // Split array into smaller arrays - function split(arr, size) { - var arrays = []; - while (arr.length > 0) { - arrays.push(arr.splice(0, size)); - } - return arrays; - } + if (self.activeDate && this.compare(dt.date, self.activeDate) === 0) { + $scope.activeDt = dt; + } - function refill( updateSelected ) { - var date = null, valid = true; + return dt; + }; - if ( ngModel.$modelValue ) { - date = new Date( ngModel.$modelValue ); + this.isDisabled = function(date) { + return $scope.disabled || + this.minDate && this.compare(date, this.minDate) < 0 || + this.maxDate && this.compare(date, this.maxDate) > 0 || + $scope.dateDisabled && $scope.dateDisabled({date: date, mode: $scope.datepickerMode}); + }; - if ( isNaN(date) ) { - valid = false; - $log.error('Datepicker directive: "ng-model" value must be a Date object, a number of milliseconds since 01.01.1970 or a string representing an RFC2822 or ISO 8601 date.'); - } else if ( updateSelected ) { - selected = date; - } - } - ngModel.$setValidity('date', valid); + this.customClass = function(date) { + return $scope.customClass({date: date, mode: $scope.datepickerMode}); + }; - var currentMode = datepickerCtrl.modes[mode], data = currentMode.getVisibleDates(selected, date); - angular.forEach(data.objects, function(obj) { - obj.disabled = datepickerCtrl.isDisabled(obj.date, mode); - }); + // Split array into smaller arrays + this.split = function(arr, size) { + var arrays = []; + while (arr.length > 0) { + arrays.push(arr.splice(0, size)); + } + return arrays; + }; - ngModel.$setValidity('date-disabled', (!date || !datepickerCtrl.isDisabled(date))); + $scope.select = function(date) { + if ($scope.datepickerMode === self.minMode) { + var dt = ngModelCtrl.$viewValue ? dateParser.fromTimezone(new Date(ngModelCtrl.$viewValue), ngModelOptions.getOption('timezone')) : new Date(0, 0, 0, 0, 0, 0, 0); + dt.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); + dt = dateParser.toTimezone(dt, ngModelOptions.getOption('timezone')); + ngModelCtrl.$setViewValue(dt); + ngModelCtrl.$render(); + } else { + self.activeDate = date; + setMode(self.modes[self.modes.indexOf($scope.datepickerMode) - 1]); + + $scope.$emit('uib:datepicker.mode'); + } - scope.rows = split(data.objects, currentMode.split); - scope.labels = data.labels || []; - scope.title = data.title; - } + $scope.$broadcast('uib:datepicker.focus'); + }; - function setMode(value) { - mode = value; - updateShowWeekNumbers(); - refill(); - } + $scope.move = function(direction) { + var year = self.activeDate.getFullYear() + direction * (self.step.years || 0), + month = self.activeDate.getMonth() + direction * (self.step.months || 0); + self.activeDate.setFullYear(year, month, 1); + self.refreshView(); + }; - ngModel.$render = function() { - refill( true ); - }; + $scope.toggleMode = function(direction) { + direction = direction || 1; - scope.select = function( date ) { - if ( mode === 0 ) { - var dt = new Date( ngModel.$modelValue ); - dt.setFullYear( date.getFullYear(), date.getMonth(), date.getDate() ); - ngModel.$setViewValue( dt ); - refill( true ); - } else { - selected = date; - setMode( mode - 1 ); - } - }; - scope.move = function(direction) { - var step = datepickerCtrl.modes[mode].step; - selected.setMonth( selected.getMonth() + direction * (step.months || 0) ); - selected.setFullYear( selected.getFullYear() + direction * (step.years || 0) ); - refill(); - }; - scope.toggleMode = function() { - setMode( (mode + 1) % datepickerCtrl.modes.length ); - }; - scope.getWeekNumber = function(row) { - return ( mode === 0 && scope.showWeekNumbers && row.length === 7 ) ? getISO8601WeekNumber(row[0].date) : null; - }; + if ($scope.datepickerMode === self.maxMode && direction === 1 || + $scope.datepickerMode === self.minMode && direction === -1) { + return; + } + + setMode(self.modes[self.modes.indexOf($scope.datepickerMode) + direction]); + + $scope.$emit('uib:datepicker.mode'); + }; - function getISO8601WeekNumber(date) { - var checkDate = new Date(date); - checkDate.setDate(checkDate.getDate() + 4 - (checkDate.getDay() || 7)); // Thursday - var time = checkDate.getTime(); - checkDate.setMonth(0); // Compare with Jan 1 - checkDate.setDate(1); - return Math.floor(Math.round((time - checkDate) / 86400000) / 7) + 1; + // Key event mapper + $scope.keys = { 13: 'enter', 32: 'space', 33: 'pageup', 34: 'pagedown', 35: 'end', 36: 'home', 37: 'left', 38: 'up', 39: 'right', 40: 'down' }; + + var focusElement = function() { + self.element[0].focus(); + }; + + // Listen for focus requests from popup directive + $scope.$on('uib:datepicker.focus', focusElement); + + $scope.keydown = function(evt) { + var key = $scope.keys[evt.which]; + + if (!key || evt.shiftKey || evt.altKey || $scope.disabled) { + return; + } + + evt.preventDefault(); + if (!self.shortcutPropagation) { + evt.stopPropagation(); + } + + if (key === 'enter' || key === 'space') { + if (self.isDisabled(self.activeDate)) { + return; // do nothing } + $scope.select(self.activeDate); + } else if (evt.ctrlKey && (key === 'up' || key === 'down')) { + $scope.toggleMode(key === 'up' ? 1 : -1); + } else { + self.handleKeyDown(key, evt); + self.refreshView(); } }; + + $element.on('keydown', function(evt) { + $scope.$apply(function() { + $scope.keydown(evt); + }); + }); + + $scope.$on('$destroy', function() { + //Clear all watch listeners on destroy + while (watchListeners.length) { + watchListeners.shift()(); + } + }); + + function setMode(mode) { + $scope.datepickerMode = mode; + $scope.datepickerOptions.datepickerMode = mode; + } + + function extractOptions(ngModelCtrl) { + var ngModelOptions; + + if (angular.version.minor < 6) { // in angular < 1.6 $options could be missing + // guarantee a value + ngModelOptions = ngModelCtrl.$options || + $scope.datepickerOptions.ngModelOptions || + datepickerConfig.ngModelOptions || + {}; + + // mimic 1.6+ api + ngModelOptions.getOption = function (key) { + return ngModelOptions[key]; + }; + } else { // in angular >=1.6 $options is always present + // ng-model-options defaults timezone to null; don't let its precedence squash a non-null value + var timezone = ngModelCtrl.$options.getOption('timezone') || + ($scope.datepickerOptions.ngModelOptions ? $scope.datepickerOptions.ngModelOptions.timezone : null) || + (datepickerConfig.ngModelOptions ? datepickerConfig.ngModelOptions.timezone : null); + + // values passed to createChild override existing values + ngModelOptions = ngModelCtrl.$options // start with a ModelOptions instance + .createChild(datepickerConfig.ngModelOptions) // lowest precedence + .createChild($scope.datepickerOptions.ngModelOptions) + .createChild(ngModelCtrl.$options) // highest precedence + .createChild({timezone: timezone}); // to keep from squashing a non-null value + } + + return ngModelOptions; + } }]) -.constant('datepickerPopupConfig', { - dateFormat: 'yyyy-MM-dd', - closeOnDateSelection: true -}) +.controller('UibDaypickerController', ['$scope', '$element', 'dateFilter', function(scope, $element, dateFilter) { + var DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; -.directive('datepickerPopup', ['$compile', '$parse', '$document', '$position', 'dateFilter', 'datepickerPopupConfig', -function ($compile, $parse, $document, $position, dateFilter, datepickerPopupConfig) { - return { - restrict: 'EA', - require: 'ngModel', - link: function(originalScope, element, attrs, ngModel) { + this.step = { months: 1 }; + this.element = $element; + function getDaysInMonth(year, month) { + return month === 1 && year % 4 === 0 && + (year % 100 !== 0 || year % 400 === 0) ? 29 : DAYS_IN_MONTH[month]; + } - var closeOnDateSelection = angular.isDefined(attrs.closeOnDateSelection) ? scope.$eval(attrs.closeOnDateSelection) : datepickerPopupConfig.closeOnDateSelection; - var dateFormat = attrs.datepickerPopup || datepickerPopupConfig.dateFormat; + this.init = function(ctrl) { + angular.extend(ctrl, this); + scope.showWeeks = ctrl.showWeeks; + ctrl.refreshView(); + }; - // create a child scope for the datepicker directive so we are not polluting original scope - var scope = originalScope.$new(); - originalScope.$on('$destroy', function() { - scope.$destroy(); - }); + this.getDates = function(startDate, n) { + var dates = new Array(n), current = new Date(startDate), i = 0, date; + while (i < n) { + date = new Date(current); + dates[i++] = date; + current.setDate(current.getDate() + 1); + } + return dates; + }; - function formatDate(value) { - return (value) ? dateFilter(value, dateFormat) : null; - } - ngModel.$formatters.push(formatDate); - - // TODO: reverse from dateFilter string to Date object - function parseDate(value) { - if ( value ) { - var date = new Date(value); - if (!isNaN(date)) { - return date; - } - } - return value; - } - ngModel.$parsers.push(parseDate); + this._refreshView = function() { + var year = this.activeDate.getFullYear(), + month = this.activeDate.getMonth(), + firstDayOfMonth = new Date(this.activeDate); - var getIsOpen, setIsOpen; - if ( attrs.isOpen ) { - getIsOpen = $parse(attrs.isOpen); - setIsOpen = getIsOpen.assign; + firstDayOfMonth.setFullYear(year, month, 1); - originalScope.$watch(getIsOpen, function updateOpen(value) { - scope.isOpen = !! value; - }); - } - scope.isOpen = getIsOpen ? getIsOpen(originalScope) : false; // Initial state + var difference = this.startingDay - firstDayOfMonth.getDay(), + numDisplayedFromPreviousMonth = difference > 0 ? + 7 - difference : - difference, + firstDate = new Date(firstDayOfMonth); - function setOpen( value ) { - if (setIsOpen) { - setIsOpen(originalScope, !!value); - } else { - scope.isOpen = !!value; - } - } + if (numDisplayedFromPreviousMonth > 0) { + firstDate.setDate(-numDisplayedFromPreviousMonth + 1); + } - var documentClickBind = function(event) { - if (scope.isOpen && event.target !== element[0]) { - scope.$apply(function() { - setOpen(false); - }); - } - }; + // 42 is the number of days on a six-week calendar + var days = this.getDates(firstDate, 42); + for (var i = 0; i < 42; i ++) { + days[i] = angular.extend(this.createDateObject(days[i], this.formatDay), { + secondary: days[i].getMonth() !== month, + uid: scope.uniqueId + '-' + i + }); + } - var elementFocusBind = function() { - scope.$apply(function() { - setOpen( true ); - }); + scope.labels = new Array(7); + for (var j = 0; j < 7; j++) { + scope.labels[j] = { + abbr: dateFilter(days[j].date, this.formatDayHeader), + full: dateFilter(days[j].date, 'EEEE') }; + } - // popup element used to display calendar - var popupEl = angular.element(''); - popupEl.attr({ - 'ng-model': 'date', - 'ng-change': 'dateSelection()' - }); - var datepickerEl = popupEl.find('datepicker'); - if (attrs.datepickerOptions) { - datepickerEl.attr(angular.extend({}, originalScope.$eval(attrs.datepickerOptions))); + scope.title = dateFilter(this.activeDate, this.formatDayTitle); + scope.rows = this.split(days, 7); + + if (scope.showWeeks) { + scope.weekNumbers = []; + var thursdayIndex = (4 + 7 - this.startingDay) % 7, + numWeeks = scope.rows.length; + for (var curWeek = 0; curWeek < numWeeks; curWeek++) { + scope.weekNumbers.push( + getISO8601WeekNumber(scope.rows[curWeek][thursdayIndex].date)); } + } + }; - var $setModelValue = $parse(attrs.ngModel).assign; + this.compare = function(date1, date2) { + var _date1 = new Date(date1.getFullYear(), date1.getMonth(), date1.getDate()); + var _date2 = new Date(date2.getFullYear(), date2.getMonth(), date2.getDate()); + _date1.setFullYear(date1.getFullYear()); + _date2.setFullYear(date2.getFullYear()); + return _date1 - _date2; + }; - // Inner change - scope.dateSelection = function() { - $setModelValue(originalScope, scope.date); - if (closeOnDateSelection) { - setOpen( false ); - } - }; + function getISO8601WeekNumber(date) { + var checkDate = new Date(date); + checkDate.setDate(checkDate.getDate() + 4 - (checkDate.getDay() || 7)); // Thursday + var time = checkDate.getTime(); + checkDate.setMonth(0); // Compare with Jan 1 + checkDate.setDate(1); + return Math.floor(Math.round((time - checkDate) / 86400000) / 7) + 1; + } - // Outter change - scope.$watch(function() { - return ngModel.$modelValue; - }, function(value) { - if (angular.isString(value)) { - var date = parseDate(value); + this.handleKeyDown = function(key, evt) { + var date = this.activeDate.getDate(); + + if (key === 'left') { + date = date - 1; + } else if (key === 'up') { + date = date - 7; + } else if (key === 'right') { + date = date + 1; + } else if (key === 'down') { + date = date + 7; + } else if (key === 'pageup' || key === 'pagedown') { + var month = this.activeDate.getMonth() + (key === 'pageup' ? - 1 : 1); + this.activeDate.setMonth(month, 1); + date = Math.min(getDaysInMonth(this.activeDate.getFullYear(), this.activeDate.getMonth()), date); + } else if (key === 'home') { + date = 1; + } else if (key === 'end') { + date = getDaysInMonth(this.activeDate.getFullYear(), this.activeDate.getMonth()); + } + this.activeDate.setDate(date); + }; +}]) - if (value && !date) { - $setModelValue(originalScope, null); - throw new Error(value + ' cannot be parsed to a date object.'); - } else { - value = date; - } - } - scope.date = value; - updatePosition(); +.controller('UibMonthpickerController', ['$scope', '$element', 'dateFilter', function(scope, $element, dateFilter) { + this.step = { years: 1 }; + this.element = $element; + + this.init = function(ctrl) { + angular.extend(ctrl, this); + ctrl.refreshView(); + }; + + this._refreshView = function() { + var months = new Array(12), + year = this.activeDate.getFullYear(), + date; + + for (var i = 0; i < 12; i++) { + date = new Date(this.activeDate); + date.setFullYear(year, i, 1); + months[i] = angular.extend(this.createDateObject(date, this.formatMonth), { + uid: scope.uniqueId + '-' + i }); + } - function addWatchableAttribute(attribute, scopeProperty, datepickerAttribute) { - if (attribute) { - originalScope.$watch($parse(attribute), function(value){ - scope[scopeProperty] = value; - }); - datepickerEl.attr(datepickerAttribute || scopeProperty, scopeProperty); - } - } - addWatchableAttribute(attrs.min, 'min'); - addWatchableAttribute(attrs.max, 'max'); - if (attrs.showWeeks) { - addWatchableAttribute(attrs.showWeeks, 'showWeeks', 'show-weeks'); - } else { - scope.showWeeks = true; - datepickerEl.attr('show-weeks', 'showWeeks'); - } - if (attrs.dateDisabled) { - datepickerEl.attr('date-disabled', attrs.dateDisabled); - } + scope.title = dateFilter(this.activeDate, this.formatMonthTitle); + scope.rows = this.split(months, this.monthColumns); + scope.yearHeaderColspan = this.monthColumns > 3 ? this.monthColumns - 2 : 1; + }; - function updatePosition() { - scope.position = $position.position(element); - scope.position.top = scope.position.top + element.prop('offsetHeight'); - } + this.compare = function(date1, date2) { + var _date1 = new Date(date1.getFullYear(), date1.getMonth()); + var _date2 = new Date(date2.getFullYear(), date2.getMonth()); + _date1.setFullYear(date1.getFullYear()); + _date2.setFullYear(date2.getFullYear()); + return _date1 - _date2; + }; - var documentBindingInitialized = false, elementFocusInitialized = false; - scope.$watch('isOpen', function(value) { - if (value) { - updatePosition(); - $document.bind('click', documentClickBind); - if(elementFocusInitialized) { - element.unbind('focus', elementFocusBind); - } - element[0].focus(); - documentBindingInitialized = true; - } else { - if(documentBindingInitialized) { - $document.unbind('click', documentClickBind); - } - element.bind('focus', elementFocusBind); - elementFocusInitialized = true; - } + this.handleKeyDown = function(key, evt) { + var date = this.activeDate.getMonth(); + + if (key === 'left') { + date = date - 1; + } else if (key === 'up') { + date = date - this.monthColumns; + } else if (key === 'right') { + date = date + 1; + } else if (key === 'down') { + date = date + this.monthColumns; + } else if (key === 'pageup' || key === 'pagedown') { + var year = this.activeDate.getFullYear() + (key === 'pageup' ? - 1 : 1); + this.activeDate.setFullYear(year); + } else if (key === 'home') { + date = 0; + } else if (key === 'end') { + date = 11; + } + this.activeDate.setMonth(date); + }; +}]) - if ( setIsOpen ) { - setIsOpen(originalScope, value); - } +.controller('UibYearpickerController', ['$scope', '$element', 'dateFilter', function(scope, $element, dateFilter) { + var columns, range; + this.element = $element; + + function getStartingYear(year) { + return parseInt((year - 1) / range, 10) * range + 1; + } + + this.yearpickerInit = function() { + columns = this.yearColumns; + range = this.yearRows * columns; + this.step = { years: range }; + }; + + this._refreshView = function() { + var years = new Array(range), date; + + for (var i = 0, start = getStartingYear(this.activeDate.getFullYear()); i < range; i++) { + date = new Date(this.activeDate); + date.setFullYear(start + i, 0, 1); + years[i] = angular.extend(this.createDateObject(date, this.formatYear), { + uid: scope.uniqueId + '-' + i }); + } - scope.today = function() { - $setModelValue(originalScope, new Date()); - }; - scope.clear = function() { - $setModelValue(originalScope, null); - }; + scope.title = [years[0].label, years[range - 1].label].join(' - '); + scope.rows = this.split(years, columns); + scope.columns = columns; + }; + + this.compare = function(date1, date2) { + return date1.getFullYear() - date2.getFullYear(); + }; - element.after($compile(popupEl)(scope)); + this.handleKeyDown = function(key, evt) { + var date = this.activeDate.getFullYear(); + + if (key === 'left') { + date = date - 1; + } else if (key === 'up') { + date = date - columns; + } else if (key === 'right') { + date = date + 1; + } else if (key === 'down') { + date = date + columns; + } else if (key === 'pageup' || key === 'pagedown') { + date += (key === 'pageup' ? - 1 : 1) * range; + } else if (key === 'home') { + date = getStartingYear(this.activeDate.getFullYear()); + } else if (key === 'end') { + date = getStartingYear(this.activeDate.getFullYear()) + range - 1; } + this.activeDate.setFullYear(date); }; }]) -.directive('datepickerPopupWrap', [function() { +.directive('uibDatepicker', function() { return { - restrict:'E', - replace: true, - transclude: true, - templateUrl: 'template/datepicker/popup.html', - link:function (scope, element, attrs) { - element.bind('click', function(event) { - event.preventDefault(); - event.stopPropagation(); - }); + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/datepicker/datepicker.html'; + }, + scope: { + datepickerOptions: '=?' + }, + require: ['uibDatepicker', '^ngModel'], + restrict: 'A', + controller: 'UibDatepickerController', + controllerAs: 'datepicker', + link: function(scope, element, attrs, ctrls) { + var datepickerCtrl = ctrls[0], ngModelCtrl = ctrls[1]; + + datepickerCtrl.init(ngModelCtrl); + } + }; +}) + +.directive('uibDaypicker', function() { + return { + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/datepicker/day.html'; + }, + require: ['^uibDatepicker', 'uibDaypicker'], + restrict: 'A', + controller: 'UibDaypickerController', + link: function(scope, element, attrs, ctrls) { + var datepickerCtrl = ctrls[0], + daypickerCtrl = ctrls[1]; + + daypickerCtrl.init(datepickerCtrl); + } + }; +}) + +.directive('uibMonthpicker', function() { + return { + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/datepicker/month.html'; + }, + require: ['^uibDatepicker', 'uibMonthpicker'], + restrict: 'A', + controller: 'UibMonthpickerController', + link: function(scope, element, attrs, ctrls) { + var datepickerCtrl = ctrls[0], + monthpickerCtrl = ctrls[1]; + + monthpickerCtrl.init(datepickerCtrl); + } + }; +}) + +.directive('uibYearpicker', function() { + return { + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/datepicker/year.html'; + }, + require: ['^uibDatepicker', 'uibYearpicker'], + restrict: 'A', + controller: 'UibYearpickerController', + link: function(scope, element, attrs, ctrls) { + var ctrl = ctrls[0]; + angular.extend(ctrl, ctrls[1]); + ctrl.yearpickerInit(); + + ctrl.refreshView(); } }; -}]); +}); diff --git a/src/datepicker/docs/demo.html b/src/datepicker/docs/demo.html index 423245b515..e083965698 100644 --- a/src/datepicker/docs/demo.html +++ b/src/datepicker/docs/demo.html @@ -1,19 +1,26 @@ + Selected date is: {{dt | date:'fullDate' }} - - - - - - - + Inline + + - Today - 2009-08-24 - Toggle Weeks - Clear - Min date - \ No newline at end of file + Today + 2009-08-24 + Clear + Min date + diff --git a/src/datepicker/docs/demo.js b/src/datepicker/docs/demo.js index 18eb51ec83..2473d9b885 100644 --- a/src/datepicker/docs/demo.js +++ b/src/datepicker/docs/demo.js @@ -1,36 +1,66 @@ -var DatepickerDemoCtrl = function ($scope, $timeout) { +angular.module('ui.bootstrap.demo').controller('DatepickerDemoCtrl', function ($scope) { $scope.today = function() { $scope.dt = new Date(); }; $scope.today(); - $scope.showWeeks = true; - $scope.toggleWeeks = function () { - $scope.showWeeks = ! $scope.showWeeks; + $scope.clear = function() { + $scope.dt = null; }; - $scope.clear = function () { - $scope.dt = null; + $scope.options = { + customClass: getDayClass, + minDate: new Date(), + showWeeks: true }; // Disable weekend selection - $scope.disabled = function(date, mode) { - return ( mode === 'day' && ( date.getDay() === 0 || date.getDay() === 6 ) ); - }; + function disabled(data) { + var date = data.date, + mode = data.mode; + return mode === 'day' && (date.getDay() === 0 || date.getDay() === 6); + } $scope.toggleMin = function() { - $scope.minDate = ( $scope.minDate ) ? null : new Date(); + $scope.options.minDate = $scope.options.minDate ? null : new Date(); }; + $scope.toggleMin(); - $scope.open = function() { - $timeout(function() { - $scope.opened = true; - }); + $scope.setDate = function(year, month, day) { + $scope.dt = new Date(year, month, day); }; - $scope.dateOptions = { - 'year-format': "'yy'", - 'starting-day': 1 - }; -}; + var tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + var afterTomorrow = new Date(tomorrow); + afterTomorrow.setDate(tomorrow.getDate() + 1); + $scope.events = [ + { + date: tomorrow, + status: 'full' + }, + { + date: afterTomorrow, + status: 'partially' + } + ]; + + function getDayClass(data) { + var date = data.date, + mode = data.mode; + if (mode === 'day') { + var dayToCheck = new Date(date).setHours(0,0,0,0); + + for (var i = 0; i < $scope.events.length; i++) { + var currentDay = new Date($scope.events[i].date).setHours(0,0,0,0); + + if (dayToCheck === currentDay) { + return $scope.events[i].status; + } + } + } + + return ''; + } +}); diff --git a/src/datepicker/docs/readme.md b/src/datepicker/docs/readme.md index d8eb2d9a53..d1928e272d 100644 --- a/src/datepicker/docs/readme.md +++ b/src/datepicker/docs/readme.md @@ -1,62 +1,161 @@ -A clean, flexible, and fully customizable date picker. - -User can navigate through months and years. -The datepicker shows dates that come from other than the main month being displayed. These other dates are also selectable. - -Everything is formatted using the [date filter](http://docs.angularjs.org/api/ng.filter:date) and thus is also localized. - -### Settings ### - -All settings can be provided as attributes in the `` or globally configured through the `datepickerConfig`. - - * `ng-model` - : - The date object. - - * `show-weeks` - _(Defaults: true)_ : - Whether to display week numbers. - - * `starting-day` - _(Defaults: 0)_ : - Starting day of the week from 0-6 (0=Sunday, ..., 6=Saturday). - - * `min` - _(Default: null)_ : - Defines the minimum available date. - - * `max` - _(Default: null)_ : - Defines the maximum available date. - - * `date-disabled (date, mode)` - _(Default: null)_ : - An optional expression to disable visible options based on passing date and current mode _(day|month|year)_. - - * `day-format` - _(Default: 'dd')_ : - Format of day in month. - - * `month-format` - _(Default: 'MMMM')_ : - Format of month in year. - - * `year-format` - _(Default: 'yyyy')_ : - Format of year in year range. - - * `year-range` - _(Default: 20)_ : - Number of years displayed in year selection. - - * `day-header-format` - _(Default: 'EEE')_ : - Format of day in week header. - - * `day-title-format` - _(Default: 'MMMM yyyy')_ : - Format of title when selecting day. - - * `month-title-format` - _(Default: 'yyyy')_ : - Format of title when selecting month. +Our datepicker is flexible and fully customizable. + +You can navigate through days, months and years. + +The datepicker has 3 modes: + +* `day` - In this mode you're presented with a 6-week calendar for a specified month. +* `month` - In this mode you can select a month within a selected year. +* `year` - In this mode you are presented with a range of years (20 by default). + +### uib-datepicker settings + +* `ng-model` + $ + - + The date object. Must be a Javascript `Date` object. You may use the `uibDateParser` service to assist in string-to-object conversion. + +* `ng-model-options` + $ + C + _(Default: `{}`)_ - + Supported [angular ngModelOptions](https://docs.angularjs.org/api/ng/directive/ngModelOptions): + * allowInvalid + * timezone + +* `template-url` + _(Default: `uib/template/datepicker/datepicker.html`)_ - + Add the ability to override the template used on the component. + +Apart from the previous settings, to configure the uib-datepicker you need to create an object in Javascript with all the options and use it on the `datepicker-options` attribute: + +* `datepicker-options` + $ - + An object to configure the datepicker in one place. + + * `customClass ({date: date, mode: mode})` - + An optional expression to add classes based on passing an object with date and current mode properties. + + * `dateDisabled ({date: date, mode: mode})` - + An optional expression to disable visible options based on passing an object with date and current mode properties. + + * `datepickerMode` + C + + _(Default: `day`)_ - + Current mode of the datepicker _(day|month|year)_. Can be used to initialize the datepicker in a specific mode. + + * `formatDay` + C + _(Default: `dd`)_ - + Format of day in month. + + * `formatMonth` + C + _(Default: `MMMM`)_ - + Format of month in year. + + * `formatYear` + C + _(Default: `yyyy`)_ - + Format of year in year range. + + * `formatDayHeader` + C + _(Default: `EEE`)_ - + Format of day in week header. + + * `formatDayTitle` + C + _(Default: `MMMM yyyy`)_ - + Format of title when selecting day. + + * `formatMonthTitle` + C + _(Default: `yyyy`)_ - + Format of title when selecting month. + + * `initDate` + + _(Default: `null`)_ - + The initial date view when no model value is specified. + + * `maxDate` + C + + _(Default: `null`)_ - + Defines the maximum available date. Requires a Javascript Date object. + + * `maxMode` + C + + _(Default: `year`)_ - + Sets an upper limit for mode. + + * `minDate` + C + + _(Default: `null`)_ - + Defines the minimum available date. Requires a Javascript Date object. + + * `minMode` + C + + _(Default: `day`)_ - + Sets a lower limit for mode. + + * `monthColumns` + C + _(Default: `3`)_ - + Number of columns displayed in month selection. + + * `ngModelOptions` + C + _(Default: `null`)_ - + Sets `ngModelOptions` for datepicker. This can be overridden through attribute usage + + * `shortcutPropagation` + C + _(Default: `false`)_ - + An option to disable the propagation of the keydown event. + + * `showWeeks` + C + _(Default: `true`)_ - + Whether to display week numbers. + + * `startingDay` + C + *(Default: `$locale.DATETIME_FORMATS.FIRSTDAYOFWEEK`)* - + Starting day of the week from 0-6 (0=Sunday, ..., 6=Saturday). + + * `yearRows` + C + _(Default: `4`)_ - + Number of rows displayed in year selection. + + * `yearColumns` + C + _(Default: `5`)_ - + Number of columns displayed in year selection. + +### Keyboard support + +Depending on datepicker's current mode, the date may refer either to day, month or year. Accordingly, the term view refers either to a month, year or year range. + + * `Left`: Move focus to the previous date. Will move to the last date of the previous view, if the current date is the first date of a view. + * `Right`: Move focus to the next date. Will move to the first date of the following view, if the current date is the last date of a view. + * `Up`: Move focus to the same column of the previous row. Will wrap to the appropriate row in the previous view. + * `Down`: Move focus to the same column of the following row. Will wrap to the appropriate row in the following view. + * `PgUp`: Move focus to the same date of the previous view. If that date does not exist, focus is placed on the last date of the month. + * `PgDn`: Move focus to the same date of the following view. If that date does not exist, focus is placed on the last date of the month. + * `Home`: Move to the first date of the view. + * `End`: Move to the last date of the view. + * `Enter`/`Space`: Select date. + * `Ctrl`+`Up`: Move to an upper mode. + * `Ctrl`+`Down`: Move to a lower mode. + * `Esc`: Will close popup, and move focus to the input. + +**Notes** + +If the date a user enters falls outside of the min-/max-date range, a `dateDisabled` validation error will show on the form. diff --git a/src/datepicker/index-nocss.js b/src/datepicker/index-nocss.js new file mode 100644 index 0000000000..95b22e2644 --- /dev/null +++ b/src/datepicker/index-nocss.js @@ -0,0 +1,13 @@ +require('../dateparser'); +require('../isClass'); +require('../../template/datepicker/datepicker.html.js'); +require('../../template/datepicker/day.html.js'); +require('../../template/datepicker/month.html.js'); +require('../../template/datepicker/year.html.js'); +require('./datepicker'); + +var MODULE_NAME = 'ui.bootstrap.module.datepicker'; + +angular.module(MODULE_NAME, ['ui.bootstrap.datepicker', 'uib/template/datepicker/datepicker.html', 'uib/template/datepicker/day.html', 'uib/template/datepicker/month.html', 'uib/template/datepicker/year.html']); + +module.exports = MODULE_NAME; diff --git a/src/datepicker/index.js b/src/datepicker/index.js new file mode 100644 index 0000000000..f7ff1cfba1 --- /dev/null +++ b/src/datepicker/index.js @@ -0,0 +1,2 @@ +require('./datepicker.css'); +module.exports = require('./index-nocss.js'); diff --git a/src/datepicker/test/datepicker.spec.js b/src/datepicker/test/datepicker.spec.js index 221c85ad04..8dd924c8d8 100644 --- a/src/datepicker/test/datepicker.spec.js +++ b/src/datepicker/test/datepicker.spec.js @@ -1,25 +1,45 @@ -describe('datepicker directive', function () { - var $rootScope, element; +describe('datepicker', function() { + var $rootScope, $compile, $templateCache, element; beforeEach(module('ui.bootstrap.datepicker')); - beforeEach(module('template/datepicker/datepicker.html')); - beforeEach(module('template/datepicker/popup.html')); - beforeEach(inject(function(_$compile_, _$rootScope_) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $rootScope.date = new Date("September 30, 2010 15:30:00"); - element = $compile('')($rootScope); - $rootScope.$digest(); + beforeEach(module('uib/template/datepicker/datepicker.html')); + beforeEach(module('uib/template/datepicker/day.html')); + beforeEach(module('uib/template/datepicker/month.html')); + beforeEach(module('uib/template/datepicker/year.html')); + beforeEach(module(function($compileProvider) { + $compileProvider.directive('dateModel', function() { + return { + restrict: 'A', + require: 'ngModel', + link: function(scope, element, attrs, modelController) { + modelController.$formatters.push(function(object) { + return new Date(object.date); + }); + + modelController.$parsers.push(function(date) { + return { + type: 'date', + date: date.toUTCString() + }; + }); + } + }; + }); })); + function getTitleCell() { + return element.find('th').eq(1); + } + + function getTitleButton() { + return getTitleCell().find('button').first(); + } + function getTitle() { - return element.find('th').eq(1).find('button').first().text(); + return getTitleButton().text(); } - function clickTitleButton(times) { - var el = element.find('th').eq(1).find('button'); - for (var i = 0, n = times || 1; i < n; i++) { - el.click(); - } + function clickTitleButton() { + getTitleButton().click(); } function clickPreviousButton(times) { @@ -29,1091 +49,1708 @@ describe('datepicker directive', function () { } } - function clickNextButton(times) { - var el = element.find('th').eq(2).find('button').eq(0); - - for (var i = 0, n = times || 1; i < n; i++) { - el.click(); - } + function clickNextButton() { + element.find('th').eq(2).find('button').eq(0).click(); } function getLabelsRow() { return element.find('thead').find('tr').eq(1); } - function getLabels() { - var els = getLabelsRow().find('th'); - - var labels = []; - for (var i = 1, n = els.length; i < n; i++) { - labels.push( els.eq(i).text() ); + function getLabels(dayMode) { + var els = getLabelsRow().find('th'), + labels = []; + for (var i = dayMode ? 1 : 0, n = els.length; i < n; i++) { + labels.push(els.eq(i).text()); } return labels; } function getWeeks() { - var rows = element.find('tbody').find('tr'); - var weeks = []; + var rows = element.find('tbody').find('tr'), + weeks = []; for (var i = 0, n = rows.length; i < n; i++) { - weeks.push( rows.eq(i).find('td').eq(0).first().text() ); + weeks.push(rows.eq(i).find('td').eq(0).first().text()); } return weeks; } - function getOptions() { + function getOptions(dayMode) { var tr = element.find('tbody').find('tr'); var rows = []; for (var j = 0, numRows = tr.length; j < numRows; j++) { var cols = tr.eq(j).find('td'), days = []; - for (var i = 1, n = cols.length; i < n; i++) { - days.push( cols.eq(i).find('button').text() ); + for (var i = dayMode ? 1 : 0, n = cols.length; i < n; i++) { + days.push(cols.eq(i).find('button').text()); } rows.push(days); } return rows; } - function _getOptionEl(rowIndex, colIndex) { - return element.find('tbody').find('tr').eq(rowIndex).find('td').eq(colIndex + 1); - } - - function clickOption(rowIndex, colIndex) { - _getOptionEl(rowIndex, colIndex).find('button').click(); + function clickOption(index) { + getAllOptionsEl().eq(index).click(); } - function isDisabledOption(rowIndex, colIndex) { - return _getOptionEl(rowIndex, colIndex).find('button').prop('disabled'); + function getAllOptionsEl(dayMode) { + return element.find('tbody').find('button'); } - function getAllOptionsEl() { - var tr = element.find('tbody').find('tr'), rows = []; - for (var i = 0; i < tr.length; i++) { - var td = tr.eq(i).find('td'), cols = []; - for (var j = 0; j < td.length; j++) { - cols.push( td.eq(j + 1) ); + function selectedElementIndex() { + var buttons = getAllOptionsEl(); + for (var i = 0; i < buttons.length; i++) { + if (angular.element(buttons[i]).hasClass('btn-info')) { + return i; } - rows.push(cols); } - return rows; } - function expectSelectedElement( row, col ) { - var options = getAllOptionsEl(); - for (var i = 0, n = options.length; i < n; i ++) { - var optionsRow = options[i]; - for (var j = 0; j < optionsRow.length; j ++) { - expect(optionsRow[j].find('button').hasClass('btn-info')).toBe( i === row && j === col ); - } + function expectSelectedElement(index) { + var buttons = getAllOptionsEl(); + angular.forEach( buttons, function(button, idx) { + expect(angular.element(button).hasClass('btn-info')).toBe(idx === index); + }); + } + + function getSelectedElement(index) { + var buttons = getAllOptionsEl(); + var el = $.grep(buttons, function(button, idx) { + return angular.element(button).hasClass('btn-info'); + })[0]; + return angular.element(el); + } + + function triggerKeyDown(element, key, ctrl) { + var keyCodes = { + 'enter': 13, + 'space': 32, + 'pageup': 33, + 'pagedown': 34, + 'end': 35, + 'home': 36, + 'left': 37, + 'up': 38, + 'right': 39, + 'down': 40, + 'esc': 27 + }; + var e = $.Event('keydown'); + e.which = keyCodes[key]; + if (ctrl) { + e.ctrlKey = true; } + element.trigger(e); } - it('is a `` element', function() { - expect(element.prop('tagName')).toBe('TABLE'); - expect(element.find('thead').find('tr').length).toBe(2); - }); + describe('$datepickerLiteralWarning', function() { + var $compile, + $log, + $scope; - it('shows the correct title', function() { - expect(getTitle()).toBe('September 2010'); - }); + it('should warn when using literals for min date by default', function() { + inject(function(_$log_, _$rootScope_, _$compile_) { + $log = _$log_; + $scope = _$rootScope_.$new(); + $compile = _$compile_; + }); - it('shows the label row & the correct day labels', function() { - expect(getLabelsRow().css('display')).not.toBe('none'); - expect(getLabels()).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); - }); + spyOn($log, 'warn'); + $scope.options = { + minDate: '1984-01-01' + }; + element = $compile('')($scope); + $scope.$digest(); - it('renders the calendar days correctly', function() { - expect(getOptions()).toEqual([ - ['29', '30', '31', '01', '02', '03', '04'], - ['05', '06', '07', '08', '09', '10', '11'], - ['12', '13', '14', '15', '16', '17', '18'], - ['19', '20', '21', '22', '23', '24', '25'], - ['26', '27', '28', '29', '30', '01', '02'] - ]); - }); + expect($log.warn).toHaveBeenCalledWith('Literal date support has been deprecated, please switch to date object usage'); + }); - it('renders the week numbers based on ISO 8601', function() { - expect(getWeeks()).toEqual(['34', '35', '36', '37', '38']); - }); + it('should suppress warning when using literals for min date', function() { + module(function($provide) { + $provide.value('$datepickerLiteralWarning', false); + }); + inject(function(_$log_, _$rootScope_, _$compile_) { + $log = _$log_; + $scope = _$rootScope_.$new(); + $compile = _$compile_; + }); - it('value is correct', function() { - expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); - }); + spyOn($log, 'warn'); + $scope.options = { + minDate: '1984-01-01' + }; + element = $compile('')($scope); + $scope.$digest(); - it('has `selected` only the correct day', function() { - expectSelectedElement( 4, 4 ); - }); + expect($log.warn).not.toHaveBeenCalled(); + }); - it('has no `selected` day when model is cleared', function() { - $rootScope.date = null; - $rootScope.$digest(); + it('should warn when using literals for max date by default', function() { + inject(function(_$log_, _$rootScope_, _$compile_) { + $log = _$log_; + $scope = _$rootScope_.$new(); + $compile = _$compile_; + }); - expect($rootScope.date).toBe(null); - expectSelectedElement( null, null ); - }); + spyOn($log, 'warn'); + $scope.options = { + maxDate: '1984-01-01' + }; + element = $compile('')($scope); + $scope.$digest(); - it('does not change current view when model is cleared', function() { - $rootScope.date = null; - $rootScope.$digest(); + expect($log.warn).toHaveBeenCalledWith('Literal date support has been deprecated, please switch to date object usage'); + }); - expect($rootScope.date).toBe(null); - expect(getTitle()).toBe('September 2010'); - }); + it('should suppress warning when using literals for max date', function() { + module(function($provide) { + $provide.value('$datepickerLiteralWarning', false); + }); + inject(function(_$log_, _$rootScope_, _$compile_) { + $log = _$log_; + $scope = _$rootScope_.$new(); + $compile = _$compile_; + }); - it('`disables` visible dates from other months', function() { - var options = getAllOptionsEl(); - for (var i = 0; i < 5; i ++) { - for (var j = 0; j < 7; j ++) { - expect(options[i][j].find('button').find('span').hasClass('muted')).toBe( ((i === 0 && j < 3) || (i === 4 && j > 4)) ); - } - } - }); + spyOn($log, 'warn'); + $scope.options = { + maxDate: '1984-01-01' + }; + element = $compile('')($scope); + $scope.$digest(); - it('updates the model when a day is clicked', function() { - clickOption(2, 3); - expect($rootScope.date).toEqual(new Date('September 15, 2010 15:30:00')); + expect($log.warn).not.toHaveBeenCalled(); + }); }); - it('moves to the previous month & renders correctly when `previous` button is clicked', function() { - clickPreviousButton(); - - expect(getTitle()).toBe('August 2010'); - expect(getLabels()).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); - expect(getOptions()).toEqual([ - ['01', '02', '03', '04', '05', '06', '07'], - ['08', '09', '10', '11', '12', '13', '14'], - ['15', '16', '17', '18', '19', '20', '21'], - ['22', '23', '24', '25', '26', '27', '28'], - ['29', '30', '31', '01', '02', '03', '04'] - ]); + describe('$datepickerSuppressError', function() { + var $compile, + $log, + $scope; - expectSelectedElement( null, null ); - }); + it('should not suppress log error message for ng-model date error by default', function() { + inject(function(_$log_, _$rootScope_, _$compile_) { + $log = _$log_; + $scope = _$rootScope_.$new(); + $compile = _$compile_; + }); - it('updates the model only when when a day is clicked in the `previous` month', function() { - clickPreviousButton(); - expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); + spyOn($log, 'error'); + element = $compile('')($scope); - clickOption(2, 3); - expect($rootScope.date).toEqual(new Date('August 18, 2010 15:30:00')); - }); + $scope.locals = { + date: 'lalala' + }; + $scope.$digest(); + expect($log.error).toHaveBeenCalled(); + }); - it('moves to the next month & renders correctly when `next` button is clicked', function() { - clickNextButton(); - - expect(getTitle()).toBe('October 2010'); - expect(getLabels()).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); - expect(getOptions()).toEqual([ - ['26', '27', '28', '29', '30', '01', '02'], - ['03', '04', '05', '06', '07', '08', '09'], - ['10', '11', '12', '13', '14', '15', '16'], - ['17', '18', '19', '20', '21', '22', '23'], - ['24', '25', '26', '27', '28', '29', '30'], - ['31', '01', '02', '03', '04', '05', '06'] - ]); - - expectSelectedElement( 0, 4 ); - }); + it('should not suppress log error message for ng-model date error when false', function() { + module(function($provide) { + $provide.value('$datepickerSuppressError', false); + }); - it('updates the model only when when a day is clicked in the `next` month', function() { - clickNextButton(); - expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); + inject(function(_$log_, _$rootScope_, _$compile_) { + $log = _$log_; + $scope = _$rootScope_.$new(); + $compile = _$compile_; + }); - clickOption(2, 3); - expect($rootScope.date).toEqual(new Date('October 13, 2010 15:30:00')); - }); + spyOn($log, 'error'); + element = $compile('')($scope); - it('updates the calendar when a day of another month is selected', function() { - clickOption(4, 5); - expect($rootScope.date).toEqual(new Date('October 01, 2010 15:30:00')); - expect(getTitle()).toBe('October 2010'); - expect(getLabels()).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); - expect(getOptions()).toEqual([ - ['26', '27', '28', '29', '30', '01', '02'], - ['03', '04', '05', '06', '07', '08', '09'], - ['10', '11', '12', '13', '14', '15', '16'], - ['17', '18', '19', '20', '21', '22', '23'], - ['24', '25', '26', '27', '28', '29', '30'], - ['31', '01', '02', '03', '04', '05', '06'] - ]); - - expectSelectedElement( 0, 5 ); - }); + $scope.locals = { + date: 'lalala' + }; + $scope.$digest(); + expect($log.error).toHaveBeenCalled(); + }); - describe('when `model` changes', function () { - function testCalendar() { - expect(getTitle()).toBe('November 2005'); - expect(getOptions()).toEqual([ - ['30', '31', '01', '02', '03', '04', '05'], - ['06', '07', '08', '09', '10', '11', '12'], - ['13', '14', '15', '16', '17', '18', '19'], - ['20', '21', '22', '23', '24', '25', '26'], - ['27', '28', '29', '30', '01', '02', '03'] - ]); - - expectSelectedElement( 1, 1 ); - } + it('should suppress log error message for ng-model date error when true', function() { + module(function($provide) { + $provide.value('$datepickerSuppressError', true); + }); - describe('to a Date object', function() { - it('updates', function() { - $rootScope.date = new Date('November 7, 2005 23:30:00'); - $rootScope.$digest(); - testCalendar(); - expect(angular.isDate($rootScope.date)).toBe(true); + inject(function(_$log_, _$rootScope_, _$compile_) { + $log = _$log_; + $scope = _$rootScope_.$new(); + $compile = _$compile_; }); + spyOn($log, 'error'); + + element = $compile('')($scope); + + $scope.locals = { + date: 'lalala' + }; + $scope.$digest(); + expect($log.error).not.toHaveBeenCalled(); }); + }); - describe('not to a Date object', function() { + describe('', function() { + beforeEach(inject(function(_$compile_, _$rootScope_, _$templateCache_) { + $compile = _$compile_; + $rootScope = _$rootScope_; + $rootScope.date = new Date('September 30, 2010 15:30:00'); + $templateCache = _$templateCache_; + })); - it('to a Number, it updates calendar', function() { - $rootScope.date = parseInt((new Date('November 7, 2005 23:30:00')).getTime(), 10); - $rootScope.$digest(); - testCalendar(); - expect(angular.isNumber($rootScope.date)).toBe(true); + describe('with no initial date', function() { + beforeEach(function() { + jasmine.clock().install(); }); - it('to a string that can be parsed by Date, it updates calendar', function() { - $rootScope.date = 'November 7, 2005 23:30:00'; - $rootScope.$digest(); - testCalendar(); - expect(angular.isString($rootScope.date)).toBe(true); + afterEach(function() { + jasmine.clock().uninstall(); }); - it('to a string that cannot be parsed by Date, it gets invalid', function() { - $rootScope.date = 'pizza'; + it('should have an active date equal to the current date', function() { + var baseTime = new Date(2015, 2, 23); + jasmine.clock().mockDate(baseTime); + + element = $compile('baz'); - clickTitleButton(); - expect(getTitle()).toBe('2010'); + element = $compile('')($rootScope); + $rootScope.$digest(); - clickTitleButton(); - expect(getTitle()).toBe('2001 - 2020'); + expect(element.html()).toBe('baz'); + }); - clickTitleButton(); - expect(getTitle()).toBe('September 2010'); - }); + it('should support custom day, month and year templates', function() { + $templateCache.put('foo/day.html', 'day'); + $templateCache.put('foo/month.html', 'month'); + $templateCache.put('foo/year.html', 'year'); - describe('month selection mode', function () { - beforeEach(function() { - clickTitleButton(); - }); + $templateCache.put('foo/bar.html', '' + + '' + + '' + + '' + + ''); - it('shows the year as title', function() { - expect(getTitle()).toBe('2010'); - }); + element = $compile('')($rootScope); + $rootScope.$digest(); - it('shows months as options', function() { - expect(getLabels()).toEqual([]); - expect(getOptions()).toEqual([ - ['January', 'February', 'March'], - ['April', 'May', 'June'], - ['July', 'August', 'September'], - ['October', 'November', 'December'] - ]); - }); + var expectedHtml = 'daymonthyear'; - it('does not change the model', function() { - expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); + expect(element.html()).toBe(expectedHtml); }); - it('has `selected` only the correct month', function() { - expectSelectedElement( 2, 2 ); - }); + it('should expose the controller in the template', function() { + $templateCache.put('uib/template/datepicker/datepicker.html', '{{datepicker.text}}'); + + element = $compile('')($rootScope); + $rootScope.$digest(); - it('moves to the previous year when `previous` button is clicked', function() { - clickPreviousButton(); + var ctrl = element.controller('uib-datepicker'); + expect(ctrl).toBeDefined(); + expect(element.html()).toBe(''); - expect(getTitle()).toBe('2009'); - expect(getLabels()).toEqual([]); - expect(getOptions()).toEqual([ - ['January', 'February', 'March'], - ['April', 'May', 'June'], - ['July', 'August', 'September'], - ['October', 'November', 'December'] - ]); + ctrl.text = 'baz'; + $rootScope.$digest(); - expectSelectedElement( null, null ); + expect(element.html()).toBe('baz'); }); - it('moves to the next year when `next` button is clicked', function() { - clickNextButton(); + describe('basic functionality', function() { + beforeEach(function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + }); - expect(getTitle()).toBe('2011'); - expect(getLabels()).toEqual([]); - expect(getOptions()).toEqual([ - ['January', 'February', 'March'], - ['April', 'May', 'June'], - ['July', 'August', 'September'], - ['October', 'November', 'December'] - ]); + it('is has a `` element', function() { + expect(element.find('table').length).toBe(1); + }); - expectSelectedElement( null, null ); - }); + it('shows the correct title', function() { + expect(getTitle()).toBe('September 2010'); + }); - it('renders correctly when a month is clicked', function() { - clickPreviousButton(5); - expect(getTitle()).toBe('2005'); - - clickOption(3, 1); - expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); - expect(getTitle()).toBe('November 2005'); - expect(getOptions()).toEqual([ - ['30', '31', '01', '02', '03', '04', '05'], - ['06', '07', '08', '09', '10', '11', '12'], - ['13', '14', '15', '16', '17', '18', '19'], - ['20', '21', '22', '23', '24', '25', '26'], - ['27', '28', '29', '30', '01', '02', '03'] - ]); - - clickOption(2, 3); - expect($rootScope.date).toEqual(new Date('November 16, 2005 15:30:00')); - }); - }); + it('shows the label row & the correct day labels', function() { + expect(getLabelsRow().css('display')).not.toBe('none'); + expect(getLabels(true)).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); + }); - describe('year selection mode', function () { - beforeEach(function() { - clickTitleButton(2); - }); + it('renders the calendar days correctly', function() { + expect(getOptions(true)).toEqual([ + ['29', '30', '31', '01', '02', '03', '04'], + ['05', '06', '07', '08', '09', '10', '11'], + ['12', '13', '14', '15', '16', '17', '18'], + ['19', '20', '21', '22', '23', '24', '25'], + ['26', '27', '28', '29', '30', '01', '02'], + ['03', '04', '05', '06', '07', '08', '09'] + ]); + }); - it('shows the year range as title', function() { - expect(getTitle()).toBe('2001 - 2020'); - }); + it('renders the week numbers based on ISO 8601', function() { + expect(getWeeks()).toEqual(['35', '36', '37', '38', '39', '40']); + }); - it('shows years as options', function() { - expect(getLabels()).toEqual([]); - expect(getOptions()).toEqual([ - ['2001', '2002', '2003', '2004', '2005'], - ['2006', '2007', '2008', '2009', '2010'], - ['2011', '2012', '2013', '2014', '2015'], - ['2016', '2017', '2018', '2019', '2020'] - ]); - }); + it('value is correct', function() { + expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); + }); - it('does not change the model', function() { - expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); - }); + it('has activeDate value of model', function() { + expect(element.controller('uibDatepicker').activeDate).toEqual(new Date('September 30, 2010 15:30:00')); + }); - it('has `selected` only the selected year', function() { - expectSelectedElement( 1, 4 ); - }); + it('has `selected` only the correct day', function() { + expectSelectedElement(32); + }); - it('moves to the previous year set when `previous` button is clicked', function() { - clickPreviousButton(); - - expect(getTitle()).toBe('1981 - 2000'); - expect(getLabels()).toEqual([]); - expect(getOptions()).toEqual([ - ['1981', '1982', '1983', '1984', '1985'], - ['1986', '1987', '1988', '1989', '1990'], - ['1991', '1992', '1993', '1994', '1995'], - ['1996', '1997', '1998', '1999', '2000'] - ]); - expectSelectedElement( null, null ); - }); + it('has no `selected` day when model is cleared', function() { + $rootScope.date = null; + $rootScope.$digest(); - it('moves to the next year set when `next` button is clicked', function() { - clickNextButton(); + expect($rootScope.date).toBe(null); + expectSelectedElement(null); + }); - expect(getTitle()).toBe('2021 - 2040'); - expect(getLabels()).toEqual([]); - expect(getOptions()).toEqual([ - ['2021', '2022', '2023', '2024', '2025'], - ['2026', '2027', '2028', '2029', '2030'], - ['2031', '2032', '2033', '2034', '2035'], - ['2036', '2037', '2038', '2039', '2040'] - ]); + it('does not change current view when model is cleared', function() { + $rootScope.date = null; + $rootScope.$digest(); - expectSelectedElement( null, null ); - }); - }); + expect($rootScope.date).toBe(null); + expect(getTitle()).toBe('September 2010'); + }); - describe('attribute `starting-day`', function () { - beforeEach(function() { - $rootScope.startingDay = 1; - element = $compile('')($rootScope); - $rootScope.$digest(); - }); + it('`disables` visible dates from other months', function() { + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).find('span').hasClass('text-muted')).toBe( index < 3 || index > 32 ); + }); + }); - it('shows the day labels rotated', function() { - expect(getLabels()).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); - }); + it('updates the model when a day is clicked', function() { + clickOption(17); + expect($rootScope.date).toEqual(new Date('September 15, 2010 15:30:00')); + }); - it('renders the calendar days correctly', function() { - expect(getOptions()).toEqual([ - ['30', '31', '01', '02', '03', '04', '05'], - ['06', '07', '08', '09', '10', '11', '12'], - ['13', '14', '15', '16', '17', '18', '19'], - ['20', '21', '22', '23', '24', '25', '26'], - ['27', '28', '29', '30', '01', '02', '03'] - ]); - }); + it('moves to the previous month & renders correctly when `previous` button is clicked', function() { + clickPreviousButton(); + + expect(getTitle()).toBe('August 2010'); + expect(getLabels(true)).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); + expect(getOptions(true)).toEqual([ + ['01', '02', '03', '04', '05', '06', '07'], + ['08', '09', '10', '11', '12', '13', '14'], + ['15', '16', '17', '18', '19', '20', '21'], + ['22', '23', '24', '25', '26', '27', '28'], + ['29', '30', '31', '01', '02', '03', '04'], + ['05', '06', '07', '08', '09', '10', '11'] + ]); + + expectSelectedElement(null, null); + }); - it('renders the week numbers correctly', function() { - expect(getWeeks()).toEqual(['35', '36', '37', '38', '39']); - }); - }); + it('updates the model only when a day is clicked in the `previous` month', function() { + clickPreviousButton(); + expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); - describe('attribute `show-weeks`', function () { - var weekHeader, weekElement; - beforeEach(function() { - $rootScope.showWeeks = false; - element = $compile('')($rootScope); - $rootScope.$digest(); + clickOption(17); + expect($rootScope.date).toEqual(new Date('August 18, 2010 15:30:00')); + }); - weekHeader = getLabelsRow().find('th').eq(0); - weekElement = element.find('tbody').find('tr').eq(1).find('td').eq(0); - }); + it('moves to the next month & renders correctly when `next` button is clicked', function() { + clickNextButton(); + + expect(getTitle()).toBe('October 2010'); + expect(getLabels(true)).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); + expect(getOptions(true)).toEqual([ + ['26', '27', '28', '29', '30', '01', '02'], + ['03', '04', '05', '06', '07', '08', '09'], + ['10', '11', '12', '13', '14', '15', '16'], + ['17', '18', '19', '20', '21', '22', '23'], + ['24', '25', '26', '27', '28', '29', '30'], + ['31', '01', '02', '03', '04', '05', '06'] + ]); + + expectSelectedElement(4); + }); - it('hides week numbers based on variable', function() { - expect(weekHeader.text()).toEqual('#'); - expect(weekHeader.css('display')).toBe('none'); - expect(weekElement.css('display')).toBe('none'); - }); + it('updates the model only when a day is clicked in the `next` month', function() { + clickNextButton(); + expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); - it('toggles week numbers', function() { - $rootScope.showWeeks = true; - $rootScope.$digest(); - expect(weekHeader.text()).toEqual('#'); - expect(weekHeader.css('display')).not.toBe('none'); - expect(weekElement.css('display')).not.toBe('none'); + clickOption(17); + expect($rootScope.date).toEqual(new Date('October 13, 2010 15:30:00')); + }); - $rootScope.showWeeks = false; - $rootScope.$digest(); - expect(weekHeader.text()).toEqual('#'); - expect(weekHeader.css('display')).toBe('none'); - expect(weekElement.css('display')).toBe('none'); - }); - }); + it('updates the calendar when a day of another month is selected', function() { + clickOption(33); + expect($rootScope.date).toEqual(new Date('October 01, 2010 15:30:00')); + expect(getTitle()).toBe('October 2010'); + expect(getLabels(true)).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); + expect(getOptions(true)).toEqual([ + ['26', '27', '28', '29', '30', '01', '02'], + ['03', '04', '05', '06', '07', '08', '09'], + ['10', '11', '12', '13', '14', '15', '16'], + ['17', '18', '19', '20', '21', '22', '23'], + ['24', '25', '26', '27', '28', '29', '30'], + ['31', '01', '02', '03', '04', '05', '06'] + ]); + + expectSelectedElement(5); + }); - describe('min attribute', function () { - beforeEach(function() { - $rootScope.mindate = new Date("September 12, 2010"); - element = $compile('')($rootScope); - $rootScope.$digest(); - }); + // issue #1697 + it('should not "jump" months', function() { + $rootScope.date = new Date('January 30, 2014'); + $rootScope.$digest(); + clickNextButton(); + expect(getTitle()).toBe('February 2014'); + clickPreviousButton(); + expect(getTitle()).toBe('January 2014'); + }); - it('disables appropriate days in current month', function() { - for (var i = 0; i < 5; i ++) { - for (var j = 0; j < 7; j ++) { - expect(isDisabledOption(i, j)).toBe( (i < 2) ); - } - } - }); + it('should not change model when going to next month - #5441', function() { + $rootScope.date = new Date('January 30, 2014'); + $rootScope.$digest(); + clickNextButton(); + expect($rootScope.date).toEqual(new Date('January 30, 2014')); + }); - it('disables appropriate days when min date changes', function() { - $rootScope.mindate = new Date("September 5, 2010"); - $rootScope.$digest(); - for (var i = 0; i < 5; i ++) { - for (var j = 0; j < 7; j ++) { - expect(isDisabledOption(i, j)).toBe( (i < 1) ); + describe('when `model` changes', function() { + function testCalendar() { + expect(getTitle()).toBe('November 2005'); + expect(getOptions(true)).toEqual([ + ['30', '31', '01', '02', '03', '04', '05'], + ['06', '07', '08', '09', '10', '11', '12'], + ['13', '14', '15', '16', '17', '18', '19'], + ['20', '21', '22', '23', '24', '25', '26'], + ['27', '28', '29', '30', '01', '02', '03'], + ['04', '05', '06', '07', '08', '09', '10'] + ]); + + expectSelectedElement(8); } - } - }); - it('invalidates when model is a disabled date', function() { - $rootScope.mindate = new Date("September 5, 2010"); - $rootScope.date = new Date("September 2, 2010"); - $rootScope.$digest(); - expect(element.hasClass('ng-invalid')).toBeTruthy(); - expect(element.hasClass('ng-invalid-date-disabled')).toBeTruthy(); - }); + describe('to a Date object', function() { + it('updates', function() { + $rootScope.date = new Date('November 7, 2005 23:30:00'); + $rootScope.$digest(); + testCalendar(); + expect(angular.isDate($rootScope.date)).toBe(true); + }); + + it('to a date that is invalid, it doesn\`t update', function() { + $rootScope.date = new Date('pizza'); + $rootScope.$digest(); + expect(getTitle()).toBe('September 2010'); + expect(angular.isDate($rootScope.date)).toBe(true); + expect(isNaN($rootScope.date)).toBe(true); + }); + }); - it('disables all days in previous month', function() { - clickPreviousButton(); - for (var i = 0; i < 5; i ++) { - for (var j = 0; j < 7; j ++) { - expect(isDisabledOption(i, j)).toBe( true ); - } - } - }); + describe('not to a Date object', function() { + it('to a Number, it updates calendar', function() { + $rootScope.date = parseInt((new Date('November 7, 2005 23:30:00')).getTime(), 10); + $rootScope.$digest(); + testCalendar(); + expect(angular.isNumber($rootScope.date)).toBe(true); + }); + + it('to a string that can be parsed by Date, it updates calendar', function() { + $rootScope.date = 'November 7, 2005 23:30:00'; + $rootScope.$digest(); + testCalendar(); + expect(angular.isString($rootScope.date)).toBe(true); + }); + + it('to a string that cannot be parsed by Date, it doesn\'t update', function() { + $rootScope.date = 'pizza'; + $rootScope.$digest(); + expect(getTitle()).toBe('September 2010'); + expect($rootScope.date).toBe('pizza'); + }); + }); + }); - it('disables no days in next month', function() { - clickNextButton(); - for (var i = 0; i < 5; i ++) { - for (var j = 0; j < 7; j ++) { - expect(isDisabledOption(i, j)).toBe( false ); - } - } - }); + it('does not loop between after max mode', function() { + expect(getTitle()).toBe('September 2010'); - it('disables appropriate months in current year', function() { - clickTitleButton(); - for (var i = 0; i < 4; i ++) { - for (var j = 0; j < 3; j ++) { - expect(isDisabledOption(i, j)).toBe( (i < 2 || (i === 2 && j < 2)) ); - } - } - }); + clickTitleButton(); + expect(getTitle()).toBe('2010'); - it('disables all months in previous year', function() { - clickTitleButton(); - clickPreviousButton(); - for (var i = 0; i < 4; i ++) { - for (var j = 0; j < 3; j ++) { - expect(isDisabledOption(i, j)).toBe( true ); - } - } - }); + clickTitleButton(); + expect(getTitle()).toBe('2001 - 2020'); - it('disables no months in next year', function() { - clickTitleButton(); - clickNextButton(); - for (var i = 0; i < 4; i ++) { - for (var j = 0; j < 3; j ++) { - expect(isDisabledOption(i, j)).toBe( false ); - } - } - }); + clickTitleButton(); + expect(getTitle()).toBe('2001 - 2020'); + }); - it('enables everything before if it is cleared', function() { - $rootScope.mindate = null; - $rootScope.date = new Date("December 20, 1949"); - $rootScope.$digest(); + describe('month selection mode', function() { + beforeEach(function() { + clickTitleButton(); + }); - clickTitleButton(); - for (var i = 0; i < 4; i ++) { - for (var j = 0; j < 3; j ++) { - expect(isDisabledOption(i, j)).toBe( false ); - } - } - }); + it('shows the year as title', function() { + expect(getTitle()).toBe('2010'); + }); - }); + it('shows months as options', function() { + expect(getOptions()).toEqual([ + ['January', 'February', 'March'], + ['April', 'May', 'June'], + ['July', 'August', 'September'], + ['October', 'November', 'December'] + ]); + }); - describe('max attribute', function () { - beforeEach(function() { - $rootScope.maxdate = new Date("September 25, 2010"); - element = $compile('')($rootScope); - $rootScope.$digest(); - }); + it('does not change the model', function() { + expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); + }); - it('disables appropriate days in current month', function() { - for (var i = 0; i < 5; i ++) { - for (var j = 0; j < 7; j ++) { - expect(isDisabledOption(i, j)).toBe( (i === 4) ); - } - } - }); + it('has `selected` only the correct month', function() { + expectSelectedElement(8); + }); - it('disables appropriate days when max date changes', function() { - $rootScope.maxdate = new Date("September 18, 2010"); - $rootScope.$digest(); - for (var i = 0; i < 5; i ++) { - for (var j = 0; j < 7; j ++) { - expect(isDisabledOption(i, j)).toBe( (i > 2) ); - } - } - }); + it('moves to the previous year when `previous` button is clicked', function() { + clickPreviousButton(); - it('invalidates when model is a disabled date', function() { - $rootScope.maxdate = new Date("September 18, 2010"); - $rootScope.$digest(); - expect(element.hasClass('ng-invalid')).toBeTruthy(); - expect(element.hasClass('ng-invalid-date-disabled')).toBeTruthy(); - }); + expect(getTitle()).toBe('2009'); + expect(getOptions()).toEqual([ + ['January', 'February', 'March'], + ['April', 'May', 'June'], + ['July', 'August', 'September'], + ['October', 'November', 'December'] + ]); - it('disables no days in previous month', function() { - clickPreviousButton(); - for (var i = 0; i < 5; i ++) { - for (var j = 0; j < 7; j ++) { - expect(isDisabledOption(i, j)).toBe( false ); - } - } - }); + expectSelectedElement(null); + }); - it('disables all days in next month', function() { - clickNextButton(); - for (var i = 0; i < 5; i ++) { - for (var j = 0; j < 7; j ++) { - expect(isDisabledOption(i, j)).toBe( true ); - } - } - }); + it('moves to the next year when `next` button is clicked', function() { + clickNextButton(); - it('disables appropriate months in current year', function() { - clickTitleButton(); - for (var i = 0; i < 4; i ++) { - for (var j = 0; j < 3; j ++) { - expect(isDisabledOption(i, j)).toBe( (i > 2 || (i === 2 && j > 2)) ); - } - } - }); + expect(getTitle()).toBe('2011'); + expect(getOptions()).toEqual([ + ['January', 'February', 'March'], + ['April', 'May', 'June'], + ['July', 'August', 'September'], + ['October', 'November', 'December'] + ]); - it('disables no months in previous year', function() { - clickTitleButton(); - clickPreviousButton(); - for (var i = 0; i < 4; i ++) { - for (var j = 0; j < 3; j ++) { - expect(isDisabledOption(i, j)).toBe( false ); - } - } - }); + expectSelectedElement(null); + }); - it('disables all months in next year', function() { - clickTitleButton(); - clickNextButton(); - for (var i = 0; i < 4; i ++) { - for (var j = 0; j < 3; j ++) { - expect(isDisabledOption(i, j)).toBe( true ); - } - } - }); + it('renders correctly when a month is clicked', function() { + clickPreviousButton(5); + expect(getTitle()).toBe('2005'); + + clickOption(10); + expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); + expect(getTitle()).toBe('November 2005'); + expect(getOptions(true)).toEqual([ + ['30', '31', '01', '02', '03', '04', '05'], + ['06', '07', '08', '09', '10', '11', '12'], + ['13', '14', '15', '16', '17', '18', '19'], + ['20', '21', '22', '23', '24', '25', '26'], + ['27', '28', '29', '30', '01', '02', '03'], + ['04', '05', '06', '07', '08', '09', '10'] + ]); + + clickOption(17); + expect($rootScope.date).toEqual(new Date('November 16, 2005 15:30:00')); + }); + }); - it('enables everything after if it is cleared', function() { - $rootScope.maxdate = null; - $rootScope.$digest(); - for (var i = 0; i < 5; i ++) { - for (var j = 0; j < 7; j ++) { - expect(isDisabledOption(i, j)).toBe( false ); - } - } - }); - }); + describe('year selection mode', function() { + beforeEach(function() { + clickTitleButton(); + clickTitleButton(); + }); - describe('date-disabled expression', function () { - beforeEach(function() { - $rootScope.dateDisabledHandler = jasmine.createSpy('dateDisabledHandler'); - element = $compile('')($rootScope); - $rootScope.$digest(); - }); + it('shows the year range as title', function() { + expect(getTitle()).toBe('2001 - 2020'); + }); - it('executes the dateDisabled expression for each visible day plus one for validation', function() { - expect($rootScope.dateDisabledHandler.calls.length).toEqual(35 + 1); - }); + it('shows years as options', function() { + expect(getOptions()).toEqual([ + ['2001', '2002', '2003', '2004', '2005'], + ['2006', '2007', '2008', '2009', '2010'], + ['2011', '2012', '2013', '2014', '2015'], + ['2016', '2017', '2018', '2019', '2020'] + ]); + }); - it('executes the dateDisabled expression for each visible month plus one for validation', function() { - $rootScope.dateDisabledHandler.reset(); - clickTitleButton(); - expect($rootScope.dateDisabledHandler.calls.length).toEqual(12 + 1); - }); + it('does not change the model', function() { + expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); + }); - it('executes the dateDisabled expression for each visible year plus one for validation', function() { - clickTitleButton(); - $rootScope.dateDisabledHandler.reset(); - clickTitleButton(); - expect($rootScope.dateDisabledHandler.calls.length).toEqual(20 + 1); - }); - }); + it('has `selected` only the selected year', function() { + expectSelectedElement(9); + }); - describe('formatting attributes', function () { - beforeEach(function() { - element = $compile('')($rootScope); - $rootScope.$digest(); - }); + it('moves to the previous year set when `previous` button is clicked', function() { + clickPreviousButton(); + + expect(getTitle()).toBe('1981 - 2000'); + expect(getOptions()).toEqual([ + ['1981', '1982', '1983', '1984', '1985'], + ['1986', '1987', '1988', '1989', '1990'], + ['1991', '1992', '1993', '1994', '1995'], + ['1996', '1997', '1998', '1999', '2000'] + ]); + expectSelectedElement(null); + }); - it('changes the title format in `day` mode', function() { - expect(getTitle()).toBe('September, 10'); - }); + it('moves to the next year set when `next` button is clicked', function() { + clickNextButton(); - it('changes the title & months format in `month` mode', function() { - clickTitleButton(); + expect(getTitle()).toBe('2021 - 2040'); + expect(getOptions()).toEqual([ + ['2021', '2022', '2023', '2024', '2025'], + ['2026', '2027', '2028', '2029', '2030'], + ['2031', '2032', '2033', '2034', '2035'], + ['2036', '2037', '2038', '2039', '2040'] + ]); - expect(getTitle()).toBe('10'); - expect(getOptions()).toEqual([ - ['Jan', 'Feb', 'Mar'], - ['Apr', 'May', 'Jun'], - ['Jul', 'Aug', 'Sep'], - ['Oct', 'Nov', 'Dec'] - ]); - }); + expectSelectedElement(null); + }); + }); - it('changes the title, year format & range in `year` mode', function() { - clickTitleButton(2); + describe('keyboard navigation', function() { + function getActiveLabel() { + return element.find('.active').eq(0).text(); + } - expect(getTitle()).toBe('01 - 10'); - expect(getOptions()).toEqual([ - ['01', '02', '03', '04', '05'], - ['06', '07', '08', '09', '10'] - ]); - }); + describe('day mode', function() { + it('will be able to activate previous day', function() { + triggerKeyDown(element, 'left'); + expect(getActiveLabel()).toBe('29'); + }); + + it('will be able to select with enter', function() { + triggerKeyDown(element, 'left'); + triggerKeyDown(element, 'enter'); + expect($rootScope.date).toEqual(new Date('September 29, 2010 15:30:00')); + }); + + it('will be able to select with space', function() { + triggerKeyDown(element, 'left'); + triggerKeyDown(element, 'space'); + expect($rootScope.date).toEqual(new Date('September 29, 2010 15:30:00')); + }); + + it('will be able to activate next day', function() { + triggerKeyDown(element, 'right'); + expect(getActiveLabel()).toBe('01'); + expect(getTitle()).toBe('October 2010'); + }); + + it('will be able to activate same day in previous week', function() { + triggerKeyDown(element, 'up'); + expect(getActiveLabel()).toBe('23'); + }); + + it('will be able to activate same day in next week', function() { + triggerKeyDown(element, 'down'); + expect(getActiveLabel()).toBe('07'); + expect(getTitle()).toBe('October 2010'); + }); + + it('will be able to activate same date in previous month', function() { + triggerKeyDown(element, 'pageup'); + expect(getActiveLabel()).toBe('30'); + expect(getTitle()).toBe('August 2010'); + }); + + it('will be able to activate same date in next month', function() { + triggerKeyDown(element, 'pagedown'); + expect(getActiveLabel()).toBe('30'); + expect(getTitle()).toBe('October 2010'); + }); + + it('will be able to activate first day of the month', function() { + triggerKeyDown(element, 'home'); + expect(getActiveLabel()).toBe('01'); + expect(getTitle()).toBe('September 2010'); + }); + + it('will be able to activate last day of the month', function() { + $rootScope.date = new Date('September 1, 2010 15:30:00'); + $rootScope.$digest(); + + triggerKeyDown(element, 'end'); + expect(getActiveLabel()).toBe('30'); + expect(getTitle()).toBe('September 2010'); + }); + + it('will be able to move to month mode', function() { + triggerKeyDown(element, 'up', true); + expect(getActiveLabel()).toBe('September'); + expect(getTitle()).toBe('2010'); + }); + + it('will not respond when trying to move to lower mode', function() { + triggerKeyDown(element, 'down', true); + expect(getActiveLabel()).toBe('30'); + expect(getTitle()).toBe('September 2010'); + }); + }); - it('shows day labels', function() { - expect(getLabels()).toEqual(['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']); - }); + describe('month mode', function() { + beforeEach(function() { + triggerKeyDown(element, 'up', true); + }); + + it('will be able to activate previous month', function() { + triggerKeyDown(element, 'left'); + expect(getActiveLabel()).toBe('August'); + }); + + it('will be able to activate next month', function() { + triggerKeyDown(element, 'right'); + expect(getActiveLabel()).toBe('October'); + }); + + it('will be able to activate same month in previous row', function() { + triggerKeyDown(element, 'up'); + expect(getActiveLabel()).toBe('June'); + }); + + it('will be able to activate same month in next row', function() { + triggerKeyDown(element, 'down'); + expect(getActiveLabel()).toBe('December'); + }); + + it('will be able to activate same date in previous year', function() { + triggerKeyDown(element, 'pageup'); + expect(getActiveLabel()).toBe('September'); + expect(getTitle()).toBe('2009'); + }); + + it('will be able to activate same date in next year', function() { + triggerKeyDown(element, 'pagedown'); + expect(getActiveLabel()).toBe('September'); + expect(getTitle()).toBe('2011'); + }); + + it('will be able to activate first month of the year', function() { + triggerKeyDown(element, 'home'); + expect(getActiveLabel()).toBe('January'); + expect(getTitle()).toBe('2010'); + }); + + it('will be able to activate last month of the year', function() { + triggerKeyDown(element, 'end'); + expect(getActiveLabel()).toBe('December'); + expect(getTitle()).toBe('2010'); + }); + + it('will be able to move to year mode', function() { + triggerKeyDown(element, 'up', true); + expect(getActiveLabel()).toBe('2010'); + expect(getTitle()).toBe('2001 - 2020'); + }); + + it('will be able to move to day mode', function() { + triggerKeyDown(element, 'down', true); + expect(getActiveLabel()).toBe('30'); + expect(getTitle()).toBe('September 2010'); + }); + + it('will move to day mode when selecting', function() { + triggerKeyDown(element, 'left', true); + triggerKeyDown(element, 'enter', true); + expect(getActiveLabel()).toBe('30'); + expect(getTitle()).toBe('August 2010'); + expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); + }); + }); - it('changes the day format', function() { - expect(getOptions()).toEqual([ - ['29', '30', '31', '1', '2', '3', '4'], - ['5', '6', '7', '8', '9', '10', '11'], - ['12', '13', '14', '15', '16', '17', '18'], - ['19', '20', '21', '22', '23', '24', '25'], - ['26', '27', '28', '29', '30', '1', '2'] - ]); - }); - }); + describe('year mode', function() { + beforeEach(function() { + triggerKeyDown(element, 'up', true); + triggerKeyDown(element, 'up', true); + }); + + it('will be able to activate previous year', function() { + triggerKeyDown(element, 'left'); + expect(getActiveLabel()).toBe('2009'); + }); + + it('will be able to activate next year', function() { + triggerKeyDown(element, 'right'); + expect(getActiveLabel()).toBe('2011'); + }); + + it('will be able to activate same year in previous row', function() { + triggerKeyDown(element, 'up'); + expect(getActiveLabel()).toBe('2005'); + }); + + it('will be able to activate same year in next row', function() { + triggerKeyDown(element, 'down'); + expect(getActiveLabel()).toBe('2015'); + }); + + it('will be able to activate same date in previous view', function() { + triggerKeyDown(element, 'pageup'); + expect(getActiveLabel()).toBe('1990'); + }); + + it('will be able to activate same date in next view', function() { + triggerKeyDown(element, 'pagedown'); + expect(getActiveLabel()).toBe('2030'); + }); + + it('will be able to activate first year of the year', function() { + triggerKeyDown(element, 'home'); + expect(getActiveLabel()).toBe('2001'); + }); + + it('will be able to activate last year of the year', function() { + triggerKeyDown(element, 'end'); + expect(getActiveLabel()).toBe('2020'); + }); + + it('will not respond when trying to move to upper mode', function() { + triggerKeyDown(element, 'up', true); + expect(getTitle()).toBe('2001 - 2020'); + }); + + it('will be able to move to month mode', function() { + triggerKeyDown(element, 'down', true); + expect(getActiveLabel()).toBe('September'); + expect(getTitle()).toBe('2010'); + }); + + it('will move to month mode when selecting', function() { + triggerKeyDown(element, 'left', true); + triggerKeyDown(element, 'enter', true); + expect(getActiveLabel()).toBe('September'); + expect(getTitle()).toBe('2009'); + expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); + }); + }); - describe('setting datepickerConfig', function() { - var originalConfig = {}; - beforeEach(inject(function(datepickerConfig) { - angular.extend(originalConfig, datepickerConfig); - datepickerConfig.startingDay = 6; - datepickerConfig.showWeeks = false; - datepickerConfig.dayFormat = 'd'; - datepickerConfig.monthFormat = 'MMM'; - datepickerConfig.yearFormat = 'yy'; - datepickerConfig.yearRange = 10; - datepickerConfig.dayHeaderFormat = 'EEEE'; - datepickerConfig.dayTitleFormat = 'MMMM, yy'; - datepickerConfig.monthTitleFormat = 'yy'; - - element = $compile('')($rootScope); - $rootScope.$digest(); - })); - afterEach(inject(function(datepickerConfig) { - // return it to the original state - angular.extend(datepickerConfig, originalConfig); - })); + describe('`aria-activedescendant`', function() { + function checkActivedescendant() { + var activeId = element.find('table').attr('aria-activedescendant'); + expect(element.find('#' + activeId + ' > button')).toHaveClass('active'); + } - it('changes the title format in `day` mode', function() { - expect(getTitle()).toBe('September, 10'); - }); + it('updates correctly', function() { + triggerKeyDown(element, 'left'); + checkActivedescendant(); + + triggerKeyDown(element, 'down'); + checkActivedescendant(); - it('changes the title & months format in `month` mode', function() { - clickTitleButton(); + triggerKeyDown(element, 'up', true); + checkActivedescendant(); - expect(getTitle()).toBe('10'); - expect(getOptions()).toEqual([ - ['Jan', 'Feb', 'Mar'], - ['Apr', 'May', 'Jun'], - ['Jul', 'Aug', 'Sep'], - ['Oct', 'Nov', 'Dec'] - ]); + triggerKeyDown(element, 'up', true); + checkActivedescendant(); + }); + }); + }); }); - it('changes the title, year format & range in `year` mode', function() { - clickTitleButton(2); + describe('attribute `datepicker-options`', function() { + describe('ngModelOptions', function() { + beforeEach(inject(function() { + $rootScope.date = new Date('2005-11-07T10:00:00.000Z'); + $rootScope.options = { + ngModelOptions: { + timezone: '+600' + } + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('supports ngModelOptions from options object and sets date to appropriate date', function() { + expectSelectedElement(8); + }); + }); - expect(getTitle()).toBe('01 - 10'); - expect(getOptions()).toEqual([ - ['01', '02', '03', '04', '05'], - ['06', '07', '08', '09', '10'] - ]); - }); + describe('startingDay', function() { + beforeEach(function() { + $rootScope.options = { + startingDay: 1 + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); - it('changes the `starting-day` & day headers & format', function() { - expect(getLabels()).toEqual(['Saturday', 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']); - expect(getOptions()).toEqual([ - ['28', '29', '30', '31', '1', '2', '3'], - ['4', '5', '6', '7', '8', '9', '10'], - ['11', '12', '13', '14', '15', '16', '17'], - ['18', '19', '20', '21', '22', '23', '24'], - ['25', '26', '27', '28', '29', '30', '1'] - ]); - }); + it('shows the day labels rotated', function() { + expect(getLabels(true)).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); + }); - it('changes initial visibility for weeks', function() { - expect(getLabelsRow().find('th').eq(0).css('display')).toBe('none'); - var tr = element.find('tbody').find('tr'); - for (var i = 0; i < 5; i++) { - expect(tr.eq(i).find('td').eq(0).css('display')).toBe('none'); - } - }); + it('renders the calendar days correctly', function() { + expect(getOptions(true)).toEqual([ + ['30', '31', '01', '02', '03', '04', '05'], + ['06', '07', '08', '09', '10', '11', '12'], + ['13', '14', '15', '16', '17', '18', '19'], + ['20', '21', '22', '23', '24', '25', '26'], + ['27', '28', '29', '30', '01', '02', '03'], + ['04', '05', '06', '07', '08', '09', '10'] + ]); + }); - }); + it('renders the week numbers correctly', function() { + expect(getWeeks()).toEqual(['35', '36', '37', '38', '39', '40']); + }); + }); - describe('controller', function () { - var ctrl, $attrs; - beforeEach(inject(function($controller) { - $rootScope.dateDisabled = null; - $attrs = {}; - ctrl = $controller('DatepickerController', { $scope: $rootScope, $attrs: $attrs }); - })); + describe('showWeeks', function() { + beforeEach(function() { + $rootScope.options = { + showWeeks: false + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); - describe('modes', function() { - var currentMode; + it('hides week numbers based on variable', function() { + expect(getLabelsRow().find('th').length).toEqual(7); + var tr = element.find('tbody').find('tr'); + for (var i = 0; i < 5; i++) { + expect(tr.eq(i).find('td').length).toEqual(7); + } + }); + }); + + describe('minDate with no initial value', function() { + beforeEach(function() { + $rootScope.options = {}; + $rootScope.date = new Date('September 10, 2010'); + element = $compile('')($rootScope); + $rootScope.$digest(); + }); - it('to be an array', function() { - expect(ctrl.modes.length).toBe(3); + it('should toggle appropriately', function() { + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(false); + }); + + $rootScope.options.minDate = new Date('September 12, 2010'); + $rootScope.$digest(); + + refreshedButtons = getAllOptionsEl(); + angular.forEach(refreshedButtons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index < 14); + }); + }); }); - describe('`day`', function() { - beforeEach(inject(function() { - currentMode = ctrl.modes[0]; - })); + describe('minDate', function() { + beforeEach(function() { + $rootScope.options = { + minDate: new Date('September 12, 2010') + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('disables appropriate days in current month', function() { + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index < 14); + }); + }); + + it('disables appropriate days when min date changes', function() { + $rootScope.options.minDate = new Date('September 5, 2010'); + $rootScope.$digest(); + + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index < 7); + }); + }); + + it('invalidates when model is a disabled date', function() { + $rootScope.options.minDate = new Date('September 5, 2010'); + $rootScope.date = new Date('September 2, 2010'); + $rootScope.$digest(); + expect(element.hasClass('ng-invalid')).toBeTruthy(); + expect(element.hasClass('ng-invalid-date-disabled')).toBeTruthy(); + }); - it('has the appropriate name', function() { - expect(currentMode.name).toBe('day'); + it('disables all days in previous month', function() { + clickPreviousButton(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(true); + }); }); - it('returns the correct date objects', function() { - var objs = currentMode.getVisibleDates(new Date('September 1, 2010'), new Date('September 30, 2010')).objects; - expect(objs.length).toBe(35); - expect(objs[1].selected).toBeFalsy(); - expect(objs[32].selected).toBeTruthy(); + it('disables no days in next month', function() { + clickNextButton(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(false); + }); }); - it('can compare two dates', function() { - expect(currentMode.compare(new Date('September 30, 2010'), new Date('September 1, 2010'))).toBeGreaterThan(0); - expect(currentMode.compare(new Date('September 1, 2010'), new Date('September 30, 2010'))).toBeLessThan(0); - expect(currentMode.compare(new Date('September 30, 2010 15:30:00'), new Date('September 30, 2010 20:30:00'))).toBe(0); + it('disables appropriate months in current year', function() { + clickTitleButton(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index < 8); + }); + }); + + it('disables all months in previous year', function() { + clickTitleButton(); + clickPreviousButton(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(true); + }); + }); + + it('disables no months in next year', function() { + clickTitleButton(); + clickNextButton(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(false); + }); + }); + + it('enables everything before if it is cleared', function() { + $rootScope.options.minDate = null; + $rootScope.date = new Date('December 20, 1949'); + $rootScope.$digest(); + + clickTitleButton(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(false); + }); + }); + + it('accepts literals, \'yyyy-MM-dd\' case', function() { + $rootScope.options.minDate = '2010-09-05'; + element = $compile('')($rootScope); + $rootScope.$digest(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index < 7); + }); }); }); - describe('`month`', function() { - beforeEach(inject(function() { - currentMode = ctrl.modes[1]; - })); + describe('maxDate with no initial value', function() { + beforeEach(function() { + $rootScope.options = {}; + $rootScope.date = new Date('September 10, 2010'); + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('should toggle appropriately', function() { + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(false); + }); + + $rootScope.options.maxDate = new Date('September 25, 2010'); + $rootScope.$digest(); + + refreshedButtons = getAllOptionsEl(); + angular.forEach(refreshedButtons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index > 27); + }); + }); + }); + + describe('maxDate', function() { + beforeEach(function() { + $rootScope.options = { + maxDate: new Date('September 25, 2010') + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('disables appropriate days in current month', function() { + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index > 27); + }); + }); + + it('disables appropriate days when max date changes', function() { + $rootScope.options.maxDate = new Date('September 18, 2010'); + $rootScope.$digest(); + + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index > 20); + }); + }); + + it('invalidates when model is a disabled date', function() { + $rootScope.options.maxDate = new Date('September 18, 2010'); + $rootScope.$digest(); + expect(element.hasClass('ng-invalid')).toBeTruthy(); + expect(element.hasClass('ng-invalid-date-disabled')).toBeTruthy(); + }); + + it('disables no days in previous month', function() { + clickPreviousButton(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(false); + }); + }); - it('has the appropriate name', function() { - expect(currentMode.name).toBe('month'); + it('disables all days in next month', function() { + clickNextButton(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(true); + }); }); - it('returns the correct date objects', function() { - var objs = currentMode.getVisibleDates(new Date('September 1, 2010'), new Date('September 30, 2010')).objects; - expect(objs.length).toBe(12); - expect(objs[1].selected).toBeFalsy(); - expect(objs[8].selected).toBeTruthy(); + it('disables appropriate months in current year', function() { + clickTitleButton(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index > 8); + }); }); - it('can compare two dates', function() { - expect(currentMode.compare(new Date('October 30, 2010'), new Date('September 01, 2010'))).toBeGreaterThan(0); - expect(currentMode.compare(new Date('September 01, 2010'), new Date('October 30, 2010'))).toBeLessThan(0); - expect(currentMode.compare(new Date('September 01, 2010'), new Date('September 30, 2010'))).toBe(0); + it('disables no months in previous year', function() { + clickTitleButton(); + clickPreviousButton(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(false); + }); + }); + + it('disables all months in next year', function() { + clickTitleButton(); + clickNextButton(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(true); + }); + }); + + it('enables everything after if it is cleared', function() { + $rootScope.options.maxDate = null; + $rootScope.$digest(); + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(false); + }); }); }); - describe('`year`', function() { - beforeEach(inject(function() { - currentMode = ctrl.modes[2]; - })); + describe('formatting', function() { + beforeEach(function() { + $rootScope.options = { + formatDay: 'd', + formatDayHeader: 'EEEE', + formatDayTitle: 'MMMM, yy', + formatMonth: 'MMM', + formatMonthTitle: 'yy', + formatYear: 'yy', + yearColumns: 4, + yearRows: 3 + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); - it('has the appropriate name', function() { - expect(currentMode.name).toBe('year'); + it('changes the title format in `day` mode', function() { + expect(getTitle()).toBe('September, 10'); }); - it('returns the correct date objects', function() { - var objs = currentMode.getVisibleDates(new Date('September 1, 2010'), new Date('September 01, 2010')).objects; - expect(objs.length).toBe(20); - expect(objs[1].selected).toBeFalsy(); - expect(objs[9].selected).toBeTruthy(); + it('changes the title & months format in `month` mode', function() { + clickTitleButton(); + + expect(getTitle()).toBe('10'); + expect(getOptions()).toEqual([ + ['Jan', 'Feb', 'Mar'], + ['Apr', 'May', 'Jun'], + ['Jul', 'Aug', 'Sep'], + ['Oct', 'Nov', 'Dec'] + ]); + }); + + it('changes the title, year format & range in `year` mode', function() { + clickTitleButton(); + clickTitleButton(); + + expect(getTitle()).toBe('05 - 16'); + expect(getOptions()).toEqual([ + ['05', '06', '07', '08'], + ['09', '10', '11', '12'], + ['13', '14', '15', '16'] + ]); }); - it('can compare two dates', function() { - expect(currentMode.compare(new Date('September 1, 2011'), new Date('October 30, 2010'))).toBeGreaterThan(0); - expect(currentMode.compare(new Date('October 30, 2010'), new Date('September 1, 2011'))).toBeLessThan(0); - expect(currentMode.compare(new Date('November 9, 2010'), new Date('September 30, 2010'))).toBe(0); + it('shows day labels', function() { + expect(getLabels(true)).toEqual(['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']); + }); + + it('changes the day format', function() { + expect(getOptions(true)).toEqual([ + ['29', '30', '31', '1', '2', '3', '4'], + ['5', '6', '7', '8', '9', '10', '11'], + ['12', '13', '14', '15', '16', '17', '18'], + ['19', '20', '21', '22', '23', '24', '25'], + ['26', '27', '28', '29', '30', '1', '2'], + ['3', '4', '5', '6', '7', '8', '9'] + ]); }); }); }); - describe('`isDisabled` function', function() { - var date = new Date("September 30, 2010 15:30:00"); + describe('setting datepickerConfig', function() { + var originalConfig = {}; + beforeEach(inject(function(uibDatepickerConfig) { + angular.extend(originalConfig, uibDatepickerConfig); + uibDatepickerConfig.formatDay = 'd'; + uibDatepickerConfig.formatMonth = 'MMM'; + uibDatepickerConfig.formatYear = 'yy'; + uibDatepickerConfig.formatDayHeader = 'EEEE'; + uibDatepickerConfig.formatDayTitle = 'MMM, yy'; + uibDatepickerConfig.formatMonthTitle = 'yy'; + uibDatepickerConfig.showWeeks = false; + uibDatepickerConfig.yearRows = 2; + uibDatepickerConfig.yearColumns = 5; + uibDatepickerConfig.startingDay = 6; + uibDatepickerConfig.monthColumns = 4; + + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + afterEach(inject(function(uibDatepickerConfig) { + // return it to the original state + Object.keys(uibDatepickerConfig).forEach(function(key) { + delete uibDatepickerConfig[key]; + }); + angular.extend(uibDatepickerConfig, originalConfig); + })); - it('to return false if no limit is set', function() { - expect(ctrl.isDisabled(date, 0)).toBeFalsy(); + it('changes the title format in `day` mode', function() { + expect(getTitle()).toBe('Sep, 10'); }); - it('to handle correctly the `min` date', function() { - ctrl.minDate = new Date('October 1, 2010'); - expect(ctrl.isDisabled(date, 0)).toBeTruthy(); - expect(ctrl.isDisabled(date)).toBeTruthy(); + it('changes the title & months format in `month` mode', function() { + clickTitleButton(); - ctrl.minDate = new Date('September 1, 2010'); - expect(ctrl.isDisabled(date, 0)).toBeFalsy(); + expect(getTitle()).toBe('10'); + expect(getOptions()).toEqual([ + ['Jan', 'Feb', 'Mar', 'Apr'], + ['May', 'Jun', 'Jul', 'Aug'], + ['Sep', 'Oct', 'Nov', 'Dec'] + ]); }); - it('to handle correctly the `max` date', function() { - ctrl.maxDate = new Date('October 1, 2010'); - expect(ctrl.isDisabled(date, 0)).toBeFalsy(); + it('shows title year button to expand to fill width in `month` mode', function() { + clickTitleButton(); + expect(getTitleCell().attr('colspan')).toBe('2'); + }); - ctrl.maxDate = new Date('September 1, 2010'); - expect(ctrl.isDisabled(date, 0)).toBeTruthy(); - expect(ctrl.isDisabled(date)).toBeTruthy(); + it('changes the title, year format & range in `year` mode', function() { + clickTitleButton(); + clickTitleButton(); + + expect(getTitle()).toBe('01 - 10'); + expect(getOptions()).toEqual([ + ['01', '02', '03', '04', '05'], + ['06', '07', '08', '09', '10'] + ]); }); - it('to handle correctly the scope `dateDisabled` expression', function() { - $rootScope.dateDisabled = function() { - return false; - }; - $rootScope.$digest(); - expect(ctrl.isDisabled(date, 0)).toBeFalsy(); + it('changes the `starting-day` & day headers & format', function() { + expect(getLabels()).toEqual(['Saturday', 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']); + expect(getOptions(false)).toEqual([ + ['28', '29', '30', '31', '1', '2', '3'], + ['4', '5', '6', '7', '8', '9', '10'], + ['11', '12', '13', '14', '15', '16', '17'], + ['18', '19', '20', '21', '22', '23', '24'], + ['25', '26', '27', '28', '29', '30', '1'], + ['2', '3', '4', '5', '6', '7', '8'] + ]); + }); - $rootScope.dateDisabled = function() { - return true; - }; + it('changes initial visibility for weeks', function() { + expect(getLabelsRow().find('th').length).toEqual(7); + var tr = element.find('tbody').find('tr'); + for (var i = 0; i < 5; i++) { + expect(tr.eq(i).find('td').length).toEqual(7); + } + }); + }); + + describe('disabled', function() { + beforeEach(function() { + element = $compile('')($rootScope); $rootScope.$digest(); - expect(ctrl.isDisabled(date, 0)).toBeTruthy(); + }); + + it('should have all dates disabled', function() { + element.find('.uib-day button').each(function(idx, elem) { + expect($(elem).prop('disabled')).toBe(true); + }); }); }); - }); - describe('as popup', function () { - var divElement, inputEl, dropdownEl, changeInputValueTo, $document; + describe('ng-disabled', function() { + beforeEach(function() { + $rootScope.disabled = false; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); - function assignElements(wrapElement) { - inputEl = wrapElement.find('input'); - dropdownEl = wrapElement.find('ul'); - element = dropdownEl.find('table'); - } + it('should toggle disabled state with value of ng-disabled', function() { + element.find('.uib-day button').each(function(idx, elem) { + expect($(elem).prop('disabled')).toBe(false); + }); - beforeEach(inject(function(_$document_, $sniffer) { - $document = _$document_; - $rootScope.date = new Date("September 30, 2010 15:30:00"); - var wrapElement = $compile('')($rootScope); - $rootScope.$digest(); - assignElements(wrapElement); + $rootScope.disabled = true; + $rootScope.$digest(); - changeInputValueTo = function (el, value) { - el.val(value); - el.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); + element.find('.uib-day button').each(function(idx, elem) { + expect($(elem).prop('disabled')).toBe(true); + }); + + $rootScope.disabled = false; $rootScope.$digest(); - }; - })); - it('to display the correct value in input', function() { - expect(inputEl.val()).toBe('2010-09-30'); + element.find('.uib-day button').each(function(idx, elem) { + expect($(elem).prop('disabled')).toBe(false); + }); + }); }); - it('does not to display datepicker initially', function() { - expect(dropdownEl.css('display')).toBe('none'); - }); + describe('datepickerConfig ngModelOptions', function() { + describe('timezone', function() { + var originalConfig = {}; + beforeEach(inject(function(uibDatepickerConfig) { + angular.extend(originalConfig, uibDatepickerConfig); + uibDatepickerConfig.ngModelOptions = { timezone: '+600' }; + $rootScope.date = new Date('2005-11-07T10:00:00.000Z'); + })); - it('displays datepicker on input focus', function() { - inputEl.focus(); - expect(dropdownEl.css('display')).not.toBe('none'); - }); + afterEach(inject(function(uibDatepickerConfig) { + // return it to the original state + angular.extend(uibDatepickerConfig, originalConfig); + })); - it('renders the calendar correctly', function() { - expect(getLabelsRow().css('display')).not.toBe('none'); - expect(getLabels()).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); - expect(getOptions()).toEqual([ - ['29', '30', '31', '01', '02', '03', '04'], - ['05', '06', '07', '08', '09', '10', '11'], - ['12', '13', '14', '15', '16', '17', '18'], - ['19', '20', '21', '22', '23', '24', '25'], - ['26', '27', '28', '29', '30', '01', '02'] - ]); - }); + describe('basics', function() { + beforeEach(function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + }); - it('updates the input when a day is clicked', function() { - clickOption(2, 3); - expect(inputEl.val()).toBe('2010-09-15'); - expect($rootScope.date).toEqual(new Date('September 15, 2010 15:30:00')); - }); + it('sets date to appropriate date', function() { + expectSelectedElement(8); + }); - it('updates the input correctly when model changes', function() { - $rootScope.date = new Date("January 10, 1983 10:00:00"); - $rootScope.$digest(); - expect(inputEl.val()).toBe('1983-01-10'); + it('updates the input when a day is clicked', function() { + clickOption(9); + expect($rootScope.date).toEqual(new Date('2005-11-08T10:00:00.000Z')); + }); + }); + + it('init date', function() { + $rootScope.options = { + initDate: new Date('2006-01-01T00:00:00.000Z') + }; + $rootScope.date = null; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getTitle()).toEqual('January 2006'); + }); + + it('min date', function() { + $rootScope.options = { + minDate: new Date('2010-10-01T00:00:00.000Z') + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getSelectedElement().prop('disabled')).toBe(true); + }); + }); }); - it('closes the dropdown when a day is clicked', function() { - inputEl.focus(); - expect(dropdownEl.css('display')).not.toBe('none'); + describe('uib-datepicker ng-model-options', function() { + describe('timezone', function() { + beforeEach(inject(function() { + $rootScope.date = new Date('2005-11-07T10:00:00.000Z'); + $rootScope.ngModelOptions = { timezone: '+600'}; + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('sets date to appropriate date', function() { + expectSelectedElement(8); + }); - clickOption(2, 3); - expect(dropdownEl.css('display')).toBe('none'); + it('updates the input when a day is clicked', function() { + clickOption(9); + expect($rootScope.date).toEqual(new Date('2005-11-08T10:00:00.000Z')); + }); + }); }); - it('updates the model when input value changes', function() { - changeInputValueTo(inputEl, 'March 5, 1980'); - expect($rootScope.date.getFullYear()).toEqual(1980); - expect($rootScope.date.getMonth()).toEqual(2); - expect($rootScope.date.getDate()).toEqual(5); + describe('with empty initial state', function() { + beforeEach(inject(function() { + $rootScope.date = null; + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('is has a `` element', function() { + expect(element.find('table').length).toBe(1); + }); + + it('is shows rows with days', function() { + expect(element.find('tbody').find('tr').length).toBeGreaterThan(3); + }); + + it('sets default 00:00:00 time for selected date', function() { + $rootScope.date = new Date('August 1, 2013'); + $rootScope.$digest(); + $rootScope.date = null; + $rootScope.$digest(); + + clickOption(14); + expect($rootScope.date).toEqual(new Date('August 11, 2013 00:00:00')); + }); }); - it('closes when click outside of calendar', function() { - $document.find('body').click(); - expect(dropdownEl.css('display')).toBe('none'); + describe('`init-date`', function() { + beforeEach(inject(function() { + $rootScope.date = null; + $rootScope.options = { + initDate: new Date('November 9, 1980') + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('does not alter the model', function() { + expect($rootScope.date).toBe(null); + }); + + it('shows the correct title', function() { + expect(getTitle()).toBe('November 1980'); + }); }); - describe('toggles programatically by `open` attribute', function () { + describe('`datepicker-mode`', function() { beforeEach(inject(function() { - $rootScope.open = true; - var wrapElement = $compile('')($rootScope); + $rootScope.date = new Date('August 11, 2013'); + $rootScope.options = { + datepickerMode: 'month' + }; + element = $compile('')($rootScope); $rootScope.$digest(); - assignElements(wrapElement); })); - it('to display initially', function() { - expect(dropdownEl.css('display')).not.toBe('none'); + it('shows the correct title', function() { + expect(getTitle()).toBe('2013'); }); - it('to close / open from scope variable', function() { - expect(dropdownEl.css('display')).not.toBe('none'); - $rootScope.open = false; + it('updates binding', function() { + clickTitleButton(); + expect($rootScope.options.datepickerMode).toBe('year'); + }); + }); + + describe('`min-mode`', function() { + beforeEach(inject(function() { + $rootScope.date = new Date('August 11, 2013'); + $rootScope.options = { + minMode: 'month', + datepickerMode: 'month' + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('does not move below it', function() { + expect(getTitle()).toBe('2013'); + clickOption( 5 ); + expect(getTitle()).toBe('2013'); + clickTitleButton(); + expect(getTitle()).toBe('2001 - 2020'); + $rootScope.options.minMode = 'year'; $rootScope.$digest(); - expect(dropdownEl.css('display')).toBe('none'); + clickOption( 5 ); + expect(getTitle()).toBe('2001 - 2020'); + }); - $rootScope.open = true; + it('updates current mode if necessary', function() { + expect(getTitle()).toBe('2013'); + $rootScope.options.minMode = 'year'; $rootScope.$digest(); - expect(dropdownEl.css('display')).not.toBe('none'); + expect(getTitle()).toBe('2001 - 2020'); }); }); - describe('custom format', function () { + describe('`max-mode`', function() { beforeEach(inject(function() { - var wrapElement = $compile('')($rootScope); + $rootScope.date = new Date('August 11, 2013'); + $rootScope.options = { + maxMode: 'month' + }; + element = $compile('')($rootScope); $rootScope.$digest(); - assignElements(wrapElement); })); - it('to display the correct value in input', function() { - expect(inputEl.val()).toBe('30-September-2010'); + it('does not move above it', function() { + expect(getTitle()).toBe('August 2013'); + clickTitleButton(); + expect(getTitle()).toBe('2013'); + clickTitleButton(); + expect(getTitle()).toBe('2013'); + clickOption( 10 ); + expect(getTitle()).toBe('November 2013'); + $rootScope.options.maxMode = 'day'; + $rootScope.$digest(); + clickTitleButton(); + expect(getTitle()).toBe('November 2013'); }); - it('updates the input when a day is clicked', function() { - clickOption(2, 3); - expect(inputEl.val()).toBe('15-September-2010'); - expect($rootScope.date).toEqual(new Date('September 15, 2010 15:30:00')); + it('disables the title button at it', function() { + expect(getTitleButton().prop('disabled')).toBe(false); + clickTitleButton(); + expect(getTitleButton().prop('disabled')).toBe(true); + clickTitleButton(); + expect(getTitleButton().prop('disabled')).toBe(true); + clickOption( 10 ); + expect(getTitleButton().prop('disabled')).toBe(false); + $rootScope.options.maxMode = 'day'; + $rootScope.$digest(); + expect(getTitleButton().prop('disabled')).toBe(true); }); - it('updates the input correctly when model changes', function() { - $rootScope.date = new Date("January 10, 1983 10:00:00"); + it('updates current mode if necessary', function() { + expect(getTitle()).toBe('August 2013'); + clickTitleButton(); + expect(getTitle()).toBe('2013'); + $rootScope.options.maxMode = 'day'; $rootScope.$digest(); - expect(inputEl.val()).toBe('10-January-1983'); + expect(getTitle()).toBe('August 2013'); }); }); - describe('use with ng-required directive', function() { + describe('with an ngModelController having formatters and parsers', function() { beforeEach(inject(function() { - $rootScope.date = ''; - var wrapElement = $compile('')($rootScope); + // Custom date object. + $rootScope.date = { type: 'date', date: 'April 1, 2015 00:00:00' }; + + // Use dateModel directive to add formatters and parsers to the + // ngModelController that translate the custom date object. + element = $compile('')($rootScope); $rootScope.$digest(); - assignElements(wrapElement); })); - it('should be invalid initially', function() { - expect(inputEl.hasClass('ng-invalid')).toBeTruthy(); - }); - it('should be valid if model has been specified', function() { - $rootScope.date = new Date(); + it('updates the view', function() { + $rootScope.date = { type: 'date', date: 'April 15, 2015 00:00:00' }; $rootScope.$digest(); - expect(inputEl.hasClass('ng-valid')).toBeTruthy(); + + expectSelectedElement(17); + }); + + it('updates the model', function() { + clickOption(17); + + expect($rootScope.date.type).toEqual('date'); + expect(new Date($rootScope.date.date)).toEqual(new Date('April 15, 2015 00:00:00')); }); }); - }); + describe('thursdays determine week count', function() { + beforeEach(inject(function() { + $rootScope.date = new Date('June 07, 2014'); + })); -}); + it('with the default starting day (sunday)', function() { + element = $compile('')($rootScope); + $rootScope.$digest(); -describe('datepicker directive with empty initial state', function () { - var $rootScope, element; - beforeEach(module('ui.bootstrap.datepicker')); - beforeEach(module('template/datepicker/datepicker.html')); - beforeEach(inject(function(_$compile_, _$rootScope_) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $rootScope.date = null; - element = $compile('')($rootScope); - $rootScope.$digest(); - })); + expect(getWeeks()).toEqual(['23', '24', '25', '26', '27', '28']); + }); - it('is a `` element', function() { - expect(element.prop('tagName')).toBe('TABLE'); - expect(element.find('thead').find('tr').length).toBe(2); - }); + describe('when starting date', function() { + it('is monday', function() { + $rootScope.options = { + startingDay: 1 + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getWeeks()).toEqual(['22', '23', '24', '25', '26', '27']); + }); + + it('is thursday', function() { + $rootScope.options = { + startingDay: 4 + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getWeeks()).toEqual(['22', '23', '24', '25', '26', '27']); + }); + + it('is saturday', function() { + $rootScope.options = { + startingDay: 6 + }; + element = $compile('')($rootScope); + $rootScope.$digest(); - it('is shows rows with days', function() { - expect(element.find('tbody').find('tr').length).toBeGreaterThan(3); + expect(getWeeks()).toEqual(['23', '24', '25', '26', '27', '28']); + }); + }); + + describe('first week in january', function() { + it('in current year', function() { + $rootScope.date = new Date('January 07, 2014'); + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getWeeks()).toEqual(['1', '2', '3', '4', '5', '6']); + }); + + it('in last year', function() { + $rootScope.date = new Date('January 07, 2010'); + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getWeeks()).toEqual(['53', '1', '2', '3', '4', '5']); + }); + }); + + describe('last week(s) in december', function() { + beforeEach(inject(function() { + $rootScope.date = new Date('December 07, 2014'); + })); + + it('in next year', function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getWeeks()).toEqual(['49', '50', '51', '52', '1', '2']); + }); + }); + }); }); -}); \ No newline at end of file +}); diff --git a/src/datepickerPopup/docs/demo.html b/src/datepickerPopup/docs/demo.html new file mode 100644 index 0000000000..499e388af1 --- /dev/null +++ b/src/datepickerPopup/docs/demo.html @@ -0,0 +1,47 @@ + + + Selected date is: {{dt | date:'fullDate' }} + + Popup + + + + + + + + + + + + + + + + + + + + + + Format: (manual alternate {{altInputFormats[0]}}) + + + + + Today + 2009-08-24 + Clear + Min date + diff --git a/src/datepickerPopup/docs/demo.js b/src/datepickerPopup/docs/demo.js new file mode 100644 index 0000000000..8494d64caa --- /dev/null +++ b/src/datepickerPopup/docs/demo.js @@ -0,0 +1,95 @@ +angular.module('ui.bootstrap.demo').controller('DatepickerPopupDemoCtrl', function ($scope) { + $scope.today = function() { + $scope.dt = new Date(); + }; + $scope.today(); + + $scope.clear = function() { + $scope.dt = null; + }; + + $scope.inlineOptions = { + customClass: getDayClass, + minDate: new Date(), + showWeeks: true + }; + + $scope.dateOptions = { + dateDisabled: disabled, + formatYear: 'yy', + maxDate: new Date(2020, 5, 22), + minDate: new Date(), + startingDay: 1 + }; + + // Disable weekend selection + function disabled(data) { + var date = data.date, + mode = data.mode; + return mode === 'day' && (date.getDay() === 0 || date.getDay() === 6); + } + + $scope.toggleMin = function() { + $scope.inlineOptions.minDate = $scope.inlineOptions.minDate ? null : new Date(); + $scope.dateOptions.minDate = $scope.inlineOptions.minDate; + }; + + $scope.toggleMin(); + + $scope.open1 = function() { + $scope.popup1.opened = true; + }; + + $scope.open2 = function() { + $scope.popup2.opened = true; + }; + + $scope.setDate = function(year, month, day) { + $scope.dt = new Date(year, month, day); + }; + + $scope.formats = ['dd-MMMM-yyyy', 'yyyy/MM/dd', 'dd.MM.yyyy', 'shortDate']; + $scope.format = $scope.formats[0]; + $scope.altInputFormats = ['M!/d!/yyyy']; + + $scope.popup1 = { + opened: false + }; + + $scope.popup2 = { + opened: false + }; + + var tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + var afterTomorrow = new Date(); + afterTomorrow.setDate(tomorrow.getDate() + 1); + $scope.events = [ + { + date: tomorrow, + status: 'full' + }, + { + date: afterTomorrow, + status: 'partially' + } + ]; + + function getDayClass(data) { + var date = data.date, + mode = data.mode; + if (mode === 'day') { + var dayToCheck = new Date(date).setHours(0,0,0,0); + + for (var i = 0; i < $scope.events.length; i++) { + var currentDay = new Date($scope.events[i].date).setHours(0,0,0,0); + + if (dayToCheck === currentDay) { + return $scope.events[i].status; + } + } + } + + return ''; + } +}); diff --git a/src/datepickerPopup/docs/readme.md b/src/datepickerPopup/docs/readme.md new file mode 100644 index 0000000000..6fc722b7c8 --- /dev/null +++ b/src/datepickerPopup/docs/readme.md @@ -0,0 +1,107 @@ +The datepicker popup is meant to be used with an input element. To understand usage of the datepicker, please refer to its documentation [here](https://angular-ui.github.io/bootstrap/#/datepicker). + +### uib-datepicker-popup settings + +The popup is a wrapper that you can use in an input to toggle a datepicker. To configure the datepicker, use `datepicker-options` as documented in the [inline datepicker](https://angular-ui.github.io/bootstrap/#/datepicker). + +* `alt-input-formats` +$ +C +_(Default: `[]`)_ - +A list of alternate formats acceptable for manual entry. + +* `clear-text` +C +_(Default: `Clear`)_ - +The text to display for the clear button. + +* `close-on-date-selection` +$ +C +_(Default: `true`)_ - +Whether to close calendar when a date is chosen. + +* `close-text` +C +_(Default: `Done`)_ - +The text to display for the close button. + +* `current-text` +C +_(Default: `Today`)_ - +The text to display for the current day button. + +* `datepicker-append-to-body` +$ +C +_(Default: `false`, Config: `appendToBody`)_ - +Append the datepicker popup element to `body`, rather than inserting after `datepicker-popup`. + +* `datepicker-options` +$ - +An object with any combination of the datepicker settings (in camelCase) used to configure the wrapped datepicker. + +* `datepicker-popup-template-url` +C +_(Default: `uib/template/datepickerPopup/popup.html`)_ - +Add the ability to override the template used on the component. + +* `datepicker-template-url` +C +_(Default: `uib/template/datepicker/datepicker.html`)_ - +Add the ability to override the template used on the component (inner uib-datepicker). + +* `is-open` +$ + +_(Default: `false`)_ - +Whether or not to show the datepicker. + +* `ng-model` + $ + - + The date object. Must be a Javascript `Date` object. You may use the `uibDateParser` service to assist in string-to-object conversion. + +* `on-open-focus` +$ +C +_(Default: `true`)_ - +Whether or not to focus the datepicker popup upon opening. + +* `show-button-bar` +$ +C +_(Default: `true`)_ - +Whether or not to display a button bar underneath the uib-datepicker. + +* `type` +C +_(Default: `text`, Config: `html5Types`)_ - +You can override the input type to be _(date|datetime-local|month)_. That will change the date format of the popup. + +* `popup-placement` + C + _(Default: `auto bottom-left`, Config: 'placement')_ - +Passing in 'auto' separated by a space before the placement will enable auto positioning, e.g: "auto bottom-left". The popup will attempt to position where it fits in the closest scrollable ancestor. Accepts: + + * `top` - popup on top, horizontally centered on input element. + * `top-left` - popup on top, left edge aligned with input element left edge. + * `top-right` - popup on top, right edge aligned with input element right edge. + * `bottom` - popup on bottom, horizontally centered on input element. + * `bottom-left` - popup on bottom, left edge aligned with input element left edge. + * `bottom-right` - popup on bottom, right edge aligned with input element right edge. + * `left` - popup on left, vertically centered on input element. + * `left-top` - popup on left, top edge aligned with input element top edge. + * `left-bottom` - popup on left, bottom edge aligned with input element bottom edge. + * `right` - popup on right, vertically centered on input element. + * `right-top` - popup on right, top edge aligned with input element top edge. + * `right-bottom` - popup on right, bottom edge aligned with input element bottom edge. + +* `uib-datepicker-popup` +C +_(Default: `yyyy-MM-dd`, Config: `datepickerConfig`)_ - +The format for displayed dates. This string can take string literals by surrounding the value with single quotes, i.e. `yyyy-MM-dd h 'o\'clock'`. + +**Notes** + +If using this directive on input type date, a native browser datepicker could also appear. diff --git a/src/datepickerPopup/index-nocss.js b/src/datepickerPopup/index-nocss.js new file mode 100644 index 0000000000..d9498dec39 --- /dev/null +++ b/src/datepickerPopup/index-nocss.js @@ -0,0 +1,10 @@ +require('../datepicker/index-nocss.js'); +require('../position/index-nocss.js'); +require('../../template/datepickerPopup/popup.html.js'); +require('./popup.js'); + +var MODULE_NAME = 'ui.bootstrap.module.datepickerPopup'; + +angular.module(MODULE_NAME, ['ui.bootstrap.datepickerPopup', 'uib/template/datepickerPopup/popup.html', 'ui.bootstrap.module.datepicker']); + +module.exports = MODULE_NAME; diff --git a/src/datepickerPopup/index.js b/src/datepickerPopup/index.js new file mode 100644 index 0000000000..15d17f7f58 --- /dev/null +++ b/src/datepickerPopup/index.js @@ -0,0 +1,4 @@ +require('../datepicker/datepicker.css'); +require('../position/position.css'); +require('./popup.css'); +module.exports = require('./index-nocss.js'); diff --git a/src/datepickerPopup/popup.css b/src/datepickerPopup/popup.css new file mode 100644 index 0000000000..a0192b6567 --- /dev/null +++ b/src/datepickerPopup/popup.css @@ -0,0 +1,9 @@ +.uib-datepicker-popup.dropdown-menu { + display: block; + float: none; + margin: 0; +} + +.uib-button-bar { + padding: 10px 9px 2px; +} diff --git a/src/datepickerPopup/popup.js b/src/datepickerPopup/popup.js new file mode 100644 index 0000000000..02c0e88a1c --- /dev/null +++ b/src/datepickerPopup/popup.js @@ -0,0 +1,466 @@ +angular.module('ui.bootstrap.datepickerPopup', ['ui.bootstrap.datepicker', 'ui.bootstrap.position']) + +.value('$datepickerPopupLiteralWarning', true) + +.constant('uibDatepickerPopupConfig', { + altInputFormats: [], + appendToBody: false, + clearText: 'Clear', + closeOnDateSelection: true, + closeText: 'Done', + currentText: 'Today', + datepickerPopup: 'yyyy-MM-dd', + datepickerPopupTemplateUrl: 'uib/template/datepickerPopup/popup.html', + datepickerTemplateUrl: 'uib/template/datepicker/datepicker.html', + html5Types: { + date: 'yyyy-MM-dd', + 'datetime-local': 'yyyy-MM-ddTHH:mm:ss.sss', + 'month': 'yyyy-MM' + }, + onOpenFocus: true, + showButtonBar: true, + placement: 'auto bottom-left' +}) + +.controller('UibDatepickerPopupController', ['$scope', '$element', '$attrs', '$compile', '$log', '$parse', '$window', '$document', '$rootScope', '$uibPosition', 'dateFilter', 'uibDateParser', 'uibDatepickerPopupConfig', '$timeout', 'uibDatepickerConfig', '$datepickerPopupLiteralWarning', +function($scope, $element, $attrs, $compile, $log, $parse, $window, $document, $rootScope, $position, dateFilter, dateParser, datepickerPopupConfig, $timeout, datepickerConfig, $datepickerPopupLiteralWarning) { + var cache = {}, + isHtml5DateInput = false; + var dateFormat, closeOnDateSelection, appendToBody, onOpenFocus, + datepickerPopupTemplateUrl, datepickerTemplateUrl, popupEl, datepickerEl, scrollParentEl, + ngModel, ngModelOptions, $popup, altInputFormats, watchListeners = []; + + this.init = function(_ngModel_) { + ngModel = _ngModel_; + ngModelOptions = extractOptions(ngModel); + closeOnDateSelection = angular.isDefined($attrs.closeOnDateSelection) ? + $scope.$parent.$eval($attrs.closeOnDateSelection) : + datepickerPopupConfig.closeOnDateSelection; + appendToBody = angular.isDefined($attrs.datepickerAppendToBody) ? + $scope.$parent.$eval($attrs.datepickerAppendToBody) : + datepickerPopupConfig.appendToBody; + onOpenFocus = angular.isDefined($attrs.onOpenFocus) ? + $scope.$parent.$eval($attrs.onOpenFocus) : datepickerPopupConfig.onOpenFocus; + datepickerPopupTemplateUrl = angular.isDefined($attrs.datepickerPopupTemplateUrl) ? + $attrs.datepickerPopupTemplateUrl : + datepickerPopupConfig.datepickerPopupTemplateUrl; + datepickerTemplateUrl = angular.isDefined($attrs.datepickerTemplateUrl) ? + $attrs.datepickerTemplateUrl : datepickerPopupConfig.datepickerTemplateUrl; + altInputFormats = angular.isDefined($attrs.altInputFormats) ? + $scope.$parent.$eval($attrs.altInputFormats) : + datepickerPopupConfig.altInputFormats; + + $scope.showButtonBar = angular.isDefined($attrs.showButtonBar) ? + $scope.$parent.$eval($attrs.showButtonBar) : + datepickerPopupConfig.showButtonBar; + + if (datepickerPopupConfig.html5Types[$attrs.type]) { + dateFormat = datepickerPopupConfig.html5Types[$attrs.type]; + isHtml5DateInput = true; + } else { + dateFormat = $attrs.uibDatepickerPopup || datepickerPopupConfig.datepickerPopup; + $attrs.$observe('uibDatepickerPopup', function(value, oldValue) { + var newDateFormat = value || datepickerPopupConfig.datepickerPopup; + // Invalidate the $modelValue to ensure that formatters re-run + // FIXME: Refactor when PR is merged: https://github.com/angular/angular.js/pull/10764 + if (newDateFormat !== dateFormat) { + dateFormat = newDateFormat; + ngModel.$modelValue = null; + + if (!dateFormat) { + throw new Error('uibDatepickerPopup must have a date format specified.'); + } + } + }); + } + + if (!dateFormat) { + throw new Error('uibDatepickerPopup must have a date format specified.'); + } + + if (isHtml5DateInput && $attrs.uibDatepickerPopup) { + throw new Error('HTML5 date input types do not support custom formats.'); + } + + // popup element used to display calendar + popupEl = angular.element(''); + + popupEl.attr({ + 'ng-model': 'date', + 'ng-change': 'dateSelection(date)', + 'template-url': datepickerPopupTemplateUrl + }); + + // datepicker element + datepickerEl = angular.element(popupEl.children()[0]); + datepickerEl.attr('template-url', datepickerTemplateUrl); + + if (!$scope.datepickerOptions) { + $scope.datepickerOptions = {}; + } + + if (isHtml5DateInput) { + if ($attrs.type === 'month') { + $scope.datepickerOptions.datepickerMode = 'month'; + $scope.datepickerOptions.minMode = 'month'; + } + } + + datepickerEl.attr('datepicker-options', 'datepickerOptions'); + + if (!isHtml5DateInput) { + // Internal API to maintain the correct ng-invalid-[key] class + ngModel.$$parserName = 'date'; + ngModel.$validators.date = validator; + ngModel.$parsers.unshift(parseDate); + ngModel.$formatters.push(function(value) { + if (ngModel.$isEmpty(value)) { + $scope.date = value; + return value; + } + + if (angular.isNumber(value)) { + value = new Date(value); + } + + $scope.date = dateParser.fromTimezone(value, ngModelOptions.getOption('timezone')); + + return dateParser.filter($scope.date, dateFormat); + }); + } else { + ngModel.$formatters.push(function(value) { + $scope.date = dateParser.fromTimezone(value, ngModelOptions.getOption('timezone')); + return value; + }); + } + + // Detect changes in the view from the text box + ngModel.$viewChangeListeners.push(function() { + $scope.date = parseDateString(ngModel.$viewValue); + }); + + $element.on('keydown', inputKeydownBind); + + $popup = $compile(popupEl)($scope); + // Prevent jQuery cache memory leak (template is now redundant after linking) + popupEl.remove(); + + if (appendToBody) { + $document.find('body').append($popup); + } else { + $element.after($popup); + } + + $scope.$on('$destroy', function() { + if ($scope.isOpen === true) { + if (!$rootScope.$$phase) { + $scope.$apply(function() { + $scope.isOpen = false; + }); + } + } + + $popup.remove(); + $element.off('keydown', inputKeydownBind); + $document.off('click', documentClickBind); + if (scrollParentEl) { + scrollParentEl.off('scroll', positionPopup); + } + angular.element($window).off('resize', positionPopup); + + //Clear all watch listeners on destroy + while (watchListeners.length) { + watchListeners.shift()(); + } + }); + }; + + $scope.getText = function(key) { + return $scope[key + 'Text'] || datepickerPopupConfig[key + 'Text']; + }; + + $scope.isDisabled = function(date) { + if (date === 'today') { + date = dateParser.fromTimezone(new Date(), ngModelOptions.getOption('timezone')); + } + + var dates = {}; + angular.forEach(['minDate', 'maxDate'], function(key) { + if (!$scope.datepickerOptions[key]) { + dates[key] = null; + } else if (angular.isDate($scope.datepickerOptions[key])) { + dates[key] = new Date($scope.datepickerOptions[key]); + } else { + if ($datepickerPopupLiteralWarning) { + $log.warn('Literal date support has been deprecated, please switch to date object usage'); + } + + dates[key] = new Date(dateFilter($scope.datepickerOptions[key], 'medium')); + } + }); + + return $scope.datepickerOptions && + dates.minDate && $scope.compare(date, dates.minDate) < 0 || + dates.maxDate && $scope.compare(date, dates.maxDate) > 0; + }; + + $scope.compare = function(date1, date2) { + return new Date(date1.getFullYear(), date1.getMonth(), date1.getDate()) - new Date(date2.getFullYear(), date2.getMonth(), date2.getDate()); + }; + + // Inner change + $scope.dateSelection = function(dt) { + $scope.date = dt; + var date = $scope.date ? dateParser.filter($scope.date, dateFormat) : null; // Setting to NULL is necessary for form validators to function + $element.val(date); + ngModel.$setViewValue(date); + + if (closeOnDateSelection) { + $scope.isOpen = false; + $element[0].focus(); + } + }; + + $scope.keydown = function(evt) { + if (evt.which === 27) { + evt.stopPropagation(); + $scope.isOpen = false; + $element[0].focus(); + } + }; + + $scope.select = function(date, evt) { + evt.stopPropagation(); + + if (date === 'today') { + var today = new Date(); + if (angular.isDate($scope.date)) { + date = new Date($scope.date); + date.setFullYear(today.getFullYear(), today.getMonth(), today.getDate()); + } else { + date = dateParser.fromTimezone(today, ngModelOptions.getOption('timezone')); + date.setHours(0, 0, 0, 0); + } + } + $scope.dateSelection(date); + }; + + $scope.close = function(evt) { + evt.stopPropagation(); + + $scope.isOpen = false; + $element[0].focus(); + }; + + $scope.disabled = angular.isDefined($attrs.disabled) || false; + if ($attrs.ngDisabled) { + watchListeners.push($scope.$parent.$watch($parse($attrs.ngDisabled), function(disabled) { + $scope.disabled = disabled; + })); + } + + $scope.$watch('isOpen', function(value) { + if (value) { + if (!$scope.disabled) { + $timeout(function() { + positionPopup(); + + if (onOpenFocus) { + $scope.$broadcast('uib:datepicker.focus'); + } + + $document.on('click', documentClickBind); + + var placement = $attrs.popupPlacement ? $attrs.popupPlacement : datepickerPopupConfig.placement; + if (appendToBody || $position.parsePlacement(placement)[2]) { + scrollParentEl = scrollParentEl || angular.element($position.scrollParent($element)); + if (scrollParentEl) { + scrollParentEl.on('scroll', positionPopup); + } + } else { + scrollParentEl = null; + } + + angular.element($window).on('resize', positionPopup); + }, 0, false); + } else { + $scope.isOpen = false; + } + } else { + $document.off('click', documentClickBind); + if (scrollParentEl) { + scrollParentEl.off('scroll', positionPopup); + } + angular.element($window).off('resize', positionPopup); + } + }); + + function cameltoDash(string) { + return string.replace(/([A-Z])/g, function($1) { return '-' + $1.toLowerCase(); }); + } + + function parseDateString(viewValue) { + var date = dateParser.parse(viewValue, dateFormat, $scope.date); + if (isNaN(date)) { + for (var i = 0; i < altInputFormats.length; i++) { + date = dateParser.parse(viewValue, altInputFormats[i], $scope.date); + if (!isNaN(date)) { + return date; + } + } + } + return date; + } + + function parseDate(viewValue) { + if (angular.isNumber(viewValue)) { + // presumably timestamp to date object + viewValue = new Date(viewValue); + } + + if (!viewValue) { + return null; + } + + if (angular.isDate(viewValue) && !isNaN(viewValue)) { + return viewValue; + } + + if (angular.isString(viewValue)) { + var date = parseDateString(viewValue); + if (!isNaN(date)) { + return dateParser.toTimezone(date, ngModelOptions.getOption('timezone')); + } + } + + return ngModelOptions.getOption('allowInvalid') ? viewValue : undefined; + } + + function validator(modelValue, viewValue) { + var value = modelValue || viewValue; + + if (!$attrs.ngRequired && !value) { + return true; + } + + if (angular.isNumber(value)) { + value = new Date(value); + } + + if (!value) { + return true; + } + + if (angular.isDate(value) && !isNaN(value)) { + return true; + } + + if (angular.isString(value)) { + return !isNaN(parseDateString(value)); + } + + return false; + } + + function documentClickBind(event) { + if (!$scope.isOpen && $scope.disabled) { + return; + } + + var popup = $popup[0]; + var dpContainsTarget = $element[0].contains(event.target); + // The popup node may not be an element node + // In some browsers (IE) only element nodes have the 'contains' function + var popupContainsTarget = popup.contains !== undefined && popup.contains(event.target); + if ($scope.isOpen && !(dpContainsTarget || popupContainsTarget)) { + $scope.$apply(function() { + $scope.isOpen = false; + }); + } + } + + function inputKeydownBind(evt) { + if (evt.which === 27 && $scope.isOpen) { + evt.preventDefault(); + evt.stopPropagation(); + $scope.$apply(function() { + $scope.isOpen = false; + }); + $element[0].focus(); + } else if (evt.which === 40 && !$scope.isOpen) { + evt.preventDefault(); + evt.stopPropagation(); + $scope.$apply(function() { + $scope.isOpen = true; + }); + } + } + + function positionPopup() { + if ($scope.isOpen) { + var dpElement = angular.element($popup[0].querySelector('.uib-datepicker-popup')); + var placement = $attrs.popupPlacement ? $attrs.popupPlacement : datepickerPopupConfig.placement; + var position = $position.positionElements($element, dpElement, placement, appendToBody); + dpElement.css({top: position.top + 'px', left: position.left + 'px'}); + if (dpElement.hasClass('uib-position-measure')) { + dpElement.removeClass('uib-position-measure'); + } + } + } + + function extractOptions(ngModelCtrl) { + var ngModelOptions; + + if (angular.version.minor < 6) { // in angular < 1.6 $options could be missing + // guarantee a value + ngModelOptions = angular.isObject(ngModelCtrl.$options) ? + ngModelCtrl.$options : + { + timezone: null + }; + + // mimic 1.6+ api + ngModelOptions.getOption = function (key) { + return ngModelOptions[key]; + }; + } else { // in angular >=1.6 $options is always present + ngModelOptions = ngModelCtrl.$options; + } + + return ngModelOptions; + } + + $scope.$on('uib:datepicker.mode', function() { + $timeout(positionPopup, 0, false); + }); +}]) + +.directive('uibDatepickerPopup', function() { + return { + require: ['ngModel', 'uibDatepickerPopup'], + controller: 'UibDatepickerPopupController', + scope: { + datepickerOptions: '=?', + isOpen: '=?', + currentText: '@', + clearText: '@', + closeText: '@' + }, + link: function(scope, element, attrs, ctrls) { + var ngModel = ctrls[0], + ctrl = ctrls[1]; + + ctrl.init(ngModel); + } + }; +}) + +.directive('uibDatepickerPopupWrap', function() { + return { + restrict: 'A', + transclude: true, + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/datepickerPopup/popup.html'; + } + }; +}); diff --git a/src/datepickerPopup/test/popup.spec.js b/src/datepickerPopup/test/popup.spec.js new file mode 100644 index 0000000000..227fdb3596 --- /dev/null +++ b/src/datepickerPopup/test/popup.spec.js @@ -0,0 +1,1678 @@ +describe('datepicker popup', function() { + var inputEl, dropdownEl, $compile, $document, $rootScope, $sniffer, + $templateCache, $timeout; + beforeEach(module('ui.bootstrap.datepickerPopup')); + beforeEach(module('uib/template/datepicker/datepicker.html')); + beforeEach(module('uib/template/datepicker/day.html')); + beforeEach(module('uib/template/datepicker/month.html')); + beforeEach(module('uib/template/datepicker/year.html')); + beforeEach(module('uib/template/datepickerPopup/popup.html')); + beforeEach(inject(function(_$compile_, _$rootScope_, _$templateCache_) { + $compile = _$compile_; + $rootScope = _$rootScope_; + $rootScope.date = new Date('September 30, 2010 15:30:00'); + $templateCache = _$templateCache_; + })); + + function getTitleButton() { + return element.find('th').eq(1).find('button').first(); + } + + function getTitle() { + return getTitleButton().text(); + } + + function clickTitleButton() { + getTitleButton().click(); + } + + function getLabelsRow() { + return element.find('thead').find('tr').eq(1); + } + + function getLabels(dayMode) { + var els = getLabelsRow().find('th'), + labels = []; + for (var i = dayMode ? 1 : 0, n = els.length; i < n; i++) { + labels.push(els.eq(i).text()); + } + return labels; + } + + function getOptions(dayMode) { + var tr = element.find('tbody').find('tr'); + var rows = []; + + for (var j = 0, numRows = tr.length; j < numRows; j++) { + var cols = tr.eq(j).find('td'), days = []; + for (var i = dayMode ? 1 : 0, n = cols.length; i < n; i++) { + days.push(cols.eq(i).find('button').text()); + } + rows.push(days); + } + return rows; + } + + function clickOption(index) { + getAllOptionsEl().eq(index).click(); + } + + function getAllOptionsEl(dayMode) { + return element.find('tbody').find('button'); + } + + function selectedElementIndex() { + var buttons = getAllOptionsEl(); + for (var i = 0; i < buttons.length; i++) { + if (angular.element(buttons[i]).hasClass('btn-info')) { + return i; + } + } + } + + function expectSelectedElement(index) { + var buttons = getAllOptionsEl(); + angular.forEach( buttons, function(button, idx) { + expect(angular.element(button).hasClass('btn-info')).toBe(idx === index); + }); + } + + function getSelectedElement(index) { + var buttons = getAllOptionsEl(); + var el = $.grep(buttons, function(button, idx) { + return angular.element(button).hasClass('btn-info'); + })[0]; + return angular.element(el); + } + + function triggerKeyDown(element, key, ctrl) { + var keyCodes = { + 'enter': 13, + 'space': 32, + 'pageup': 33, + 'pagedown': 34, + 'end': 35, + 'home': 36, + 'left': 37, + 'up': 38, + 'right': 39, + 'down': 40, + 'esc': 27 + }; + var e = $.Event('keydown'); + e.which = keyCodes[key]; + if (ctrl) { + e.ctrlKey = true; + } + element.trigger(e); + } + + function assignElements(wrapElement) { + inputEl = wrapElement.find('input'); + dropdownEl = wrapElement.find('ul'); + element = dropdownEl.find('table'); + } + + function changeInputValueTo(el, value) { + el.val(value); + el.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); + $rootScope.$digest(); + } + + describe('basic', function() { + var wrapElement, inputEl, dropdownEl; + + function assignElements(wrapElement) { + inputEl = wrapElement.find('input'); + dropdownEl = wrapElement.find('ul'); + element = dropdownEl.find('table'); + } + + beforeEach(function() { + $rootScope.date = new Date('September 30, 2010 15:30:00'); + $rootScope.isopen = true; + wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + }); + + it('should stop click event from bubbling from today button', function() { + var bubbled = false; + wrapElement.on('click', function() { + bubbled = true; + }); + + wrapElement.find('.uib-datepicker-current').trigger('click'); + + expect(bubbled).toBe(false); + }); + + it('should stop click event from bubbling from clear button', function() { + var bubbled = false; + wrapElement.on('click', function() { + bubbled = true; + }); + + wrapElement.find('.uib-clear').trigger('click'); + + expect(bubbled).toBe(false); + }); + + it('should stop click event from bubbling from close button', function() { + var bubbled = false; + wrapElement.on('click', function() { + bubbled = true; + }); + + wrapElement.find('.uib-close').trigger('click'); + + expect(bubbled).toBe(false); + }); + }); + + describe('ngModelOptions allowInvalid', function() { + beforeEach(inject(function(_$sniffer_) { + $sniffer = _$sniffer_; + + $rootScope.date = new Date('September 30, 2010 15:30:00'); + $rootScope.ngModelOptions = { + allowInvalid: true + }; + element = $compile('')($rootScope); + inputEl = element.find('input'); + $rootScope.$digest(); + })); + + function changeInputValueTo(el, value) { + el.val(value); + el.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); + $rootScope.$digest(); + } + + it('should update ng-model even if the date is invalid when allowInvalid is true', function() { + changeInputValueTo(inputEl, 'pizza'); + expect($rootScope.date).toBe('pizza'); + expect(inputEl.val()).toBe('pizza'); + }); + }); + + describe('setting datepickerPopupConfig', function() { + var originalConfig = {}; + beforeEach(inject(function(uibDatepickerPopupConfig) { + angular.extend(originalConfig, uibDatepickerPopupConfig); + uibDatepickerPopupConfig.datepickerPopup = 'MM-dd-yyyy'; + + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + afterEach(inject(function(uibDatepickerPopupConfig) { + // return it to the original state + angular.extend(uibDatepickerPopupConfig, originalConfig); + })); + + it('changes date format', function() { + expect(element.val()).toEqual('09-30-2010'); + }); + }); + + describe('setting datepickerPopupConfig inside ng-if', function() { + var originalConfig = {}; + beforeEach(inject(function(uibDatepickerPopupConfig) { + angular.extend(originalConfig, uibDatepickerPopupConfig); + uibDatepickerPopupConfig.datepickerPopup = 'MM-dd-yyyy'; + + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + afterEach(inject(function(uibDatepickerPopupConfig) { + // return it to the original state + angular.extend(uibDatepickerPopupConfig, originalConfig); + })); + + it('changes date format', function() { + expect(element.find('input').val()).toEqual('09-30-2010'); + }); + }); + + describe('initially', function() { + beforeEach(inject(function(_$document_, _$sniffer_) { + $document = _$document_; + $sniffer = _$sniffer_; + $rootScope.isopen = true; + $rootScope.date = new Date('September 30, 2010 15:30:00'); + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('does not to display datepicker initially', function() { + expect(dropdownEl.length).toBe(0); + }); + + it('to display the correct value in input', function() { + expect(inputEl.val()).toBe('2010-09-30'); + }); + }); + + describe('initially opened', function() { + var wrapElement; + + beforeEach(inject(function(_$document_, _$sniffer_, _$timeout_) { + $document = _$document_; + $sniffer = _$sniffer_; + $timeout = _$timeout_; + $rootScope.isopen = true; + $rootScope.date = new Date('September 30, 2010 15:30:00'); + wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('datepicker is displayed', function() { + expect(dropdownEl.length).toBe(1); + }); + + it('renders the calendar correctly', function() { + expect(getLabelsRow().css('display')).not.toBe('none'); + expect(getLabels(true)).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); + expect(getOptions(true)).toEqual([ + ['29', '30', '31', '01', '02', '03', '04'], + ['05', '06', '07', '08', '09', '10', '11'], + ['12', '13', '14', '15', '16', '17', '18'], + ['19', '20', '21', '22', '23', '24', '25'], + ['26', '27', '28', '29', '30', '01', '02'], + ['03', '04', '05', '06', '07', '08', '09'] + ]); + }); + + it('updates the input when a day is clicked', function() { + clickOption(17); + expect(inputEl.val()).toBe('2010-09-15'); + expect($rootScope.date).toEqual(new Date('September 15, 2010 15:30:00')); + }); + + it('should mark the input field dirty when a day is clicked', function() { + expect(inputEl).toHaveClass('ng-pristine'); + clickOption(17); + expect(inputEl).toHaveClass('ng-dirty'); + }); + + it('updates the input correctly when model changes', function() { + $rootScope.date = new Date('January 10, 1983 10:00:00'); + $rootScope.$digest(); + expect(inputEl.val()).toBe('1983-01-10'); + }); + + it('closes the dropdown when a day is clicked', function() { + expect(dropdownEl.length).toBe(1); + + clickOption(17); + assignElements(wrapElement); + expect(dropdownEl.length).toBe(0); + }); + + it('updates the model & calendar when input value changes', function() { + changeInputValueTo(inputEl, '2010-09-15'); + + expect($rootScope.date.getFullYear()).toEqual(2010); + expect($rootScope.date.getMonth()).toEqual(8); + expect($rootScope.date.getDate()).toEqual(15); + + expect(getOptions(true)).toEqual([ + ['29', '30', '31', '01', '02', '03', '04'], + ['05', '06', '07', '08', '09', '10', '11'], + ['12', '13', '14', '15', '16', '17', '18'], + ['19', '20', '21', '22', '23', '24', '25'], + ['26', '27', '28', '29', '30', '01', '02'], + ['03', '04', '05', '06', '07', '08', '09'] + ]); + expectSelectedElement(17); + }); + + it('closes when click outside of calendar', function() { + expect(dropdownEl.length).toBe(1); + + $timeout.flush(0); + $document.find('body').click(); + assignElements(wrapElement); + expect(dropdownEl.length).toBe(0); + }); + + it('sets `ng-invalid` for invalid input', function() { + changeInputValueTo(inputEl, 'pizza'); + + expect(inputEl).toHaveClass('ng-invalid'); + expect(inputEl).toHaveClass('ng-invalid-date'); + expect($rootScope.date).toBeUndefined(); + expect(inputEl.val()).toBe('pizza'); + }); + + it('unsets `ng-invalid` for valid input', function() { + changeInputValueTo(inputEl, 'pizza'); + expect(inputEl).toHaveClass('ng-invalid-date'); + + $rootScope.date = new Date('August 11, 2013'); + $rootScope.$digest(); + expect(inputEl).not.toHaveClass('ng-invalid'); + expect(inputEl).not.toHaveClass('ng-invalid-date'); + }); + + describe('focus', function () { + beforeEach(function() { + var body = $document.find('body'); + body.append(inputEl); + body.append(dropdownEl); + }); + + afterEach(function() { + inputEl.remove(); + dropdownEl.remove(); + }); + + it('returns to the input when ESC key is pressed in the popup and closes', function() { + expect(dropdownEl.length).toBe(1); + + dropdownEl.find('button').eq(0).focus(); + expect(document.activeElement.tagName).toBe('BUTTON'); + + triggerKeyDown(dropdownEl, 'esc'); + assignElements(wrapElement); + expect(dropdownEl.length).toBe(0); + expect(document.activeElement.tagName).toBe('INPUT'); + }); + + it('returns to the input when ESC key is pressed in the input and closes', function() { + expect(dropdownEl.length).toBe(1); + + dropdownEl.find('button').eq(0).focus(); + expect(document.activeElement.tagName).toBe('BUTTON'); + + triggerKeyDown(inputEl, 'esc'); + $rootScope.$digest(); + assignElements(wrapElement); + expect(dropdownEl.length).toBe(0); + expect(document.activeElement.tagName).toBe('INPUT'); + }); + + it('stops the ESC key from propagating if the dropdown is open, but not when closed', function() { + var documentKey = -1; + var getKey = function(evt) { documentKey = evt.which; }; + $document.on('keydown', getKey); + + triggerKeyDown(inputEl, 'esc'); + expect(documentKey).toBe(-1); + + triggerKeyDown(inputEl, 'esc'); + expect(documentKey).toBe(27); + + $document.off('keydown', getKey); + }); + }); + + describe('works with HTML5 date input types', function() { + var date2 = new Date('October 1, 2010 12:34:56.789'); + beforeEach(inject(function(_$document_) { + $document = _$document_; + $rootScope.isopen = true; + $rootScope.date = new Date('September 30, 2010 15:30:00'); + })); + + it('works as date', function() { + setupInputWithType('date'); + expect(dropdownEl.length).toBe(1); + expect(inputEl.val()).toBe('2010-09-30'); + + changeInputValueTo(inputEl, '1980-03-05'); + + expect($rootScope.date.getFullYear()).toEqual(1980); + expect($rootScope.date.getMonth()).toEqual(2); + expect($rootScope.date.getDate()).toEqual(5); + + expect(getOptions(true)).toEqual([ + ['24', '25', '26', '27', '28', '29', '01'], + ['02', '03', '04', '05', '06', '07', '08'], + ['09', '10', '11', '12', '13', '14', '15'], + ['16', '17', '18', '19', '20', '21', '22'], + ['23', '24', '25', '26', '27', '28', '29'], + ['30', '31', '01', '02', '03', '04', '05'] + ]); + expect(selectedElementIndex()).toEqual(10); + }); + + it('works as datetime-local', function() { + setupInputWithType('datetime-local'); + expect(inputEl.val()).toBe('2010-09-30T15:30:00.000'); + + changeInputValueTo(inputEl, '1980-03-05T12:34:56.000'); + + expect($rootScope.date.getFullYear()).toEqual(1980); + expect($rootScope.date.getMonth()).toEqual(2); + expect($rootScope.date.getDate()).toEqual(5); + + expect(getOptions(true)).toEqual([ + ['24', '25', '26', '27', '28', '29', '01'], + ['02', '03', '04', '05', '06', '07', '08'], + ['09', '10', '11', '12', '13', '14', '15'], + ['16', '17', '18', '19', '20', '21', '22'], + ['23', '24', '25', '26', '27', '28', '29'], + ['30', '31', '01', '02', '03', '04', '05'] + ]); + expect(selectedElementIndex()).toEqual(10); + }); + + it('works as month', function() { + setupInputWithType('month'); + expect(inputEl.val()).toBe('2010-09'); + + changeInputValueTo(inputEl, '1980-03'); + + expect($rootScope.date.getFullYear()).toEqual(1980); + expect($rootScope.date.getMonth()).toEqual(2); + expect($rootScope.date.getDate()).toEqual(30); + + expect(getOptions()).toEqual([ + ['January', 'February', 'March'], + ['April', 'May', 'June'], + ['July', 'August', 'September'], + ['October', 'November', 'December'] + ]); + expect(selectedElementIndex()).toEqual(2); + }); + + function setupInputWithType(type) { + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + } + }); + }); + + describe('works with ngModelOptions', function() { + var $timeout; + + beforeEach(inject(function(_$document_, _$sniffer_, _$timeout_) { + $document = _$document_; + $timeout = _$timeout_; + $rootScope.isopen = true; + $rootScope.date = new Date('September 30, 2010 15:30:00'); + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('should change model and update calendar after debounce timeout', function() { + changeInputValueTo(inputEl, '1980-03-05'); + + expect($rootScope.date.getFullYear()).toEqual(2010); + expect($rootScope.date.getMonth()).toEqual(9 - 1); + expect($rootScope.date.getDate()).toEqual(30); + + expect(getOptions(true)).toEqual([ + ['29', '30', '31', '01', '02', '03', '04'], + ['05', '06', '07', '08', '09', '10', '11'], + ['12', '13', '14', '15', '16', '17', '18'], + ['19', '20', '21', '22', '23', '24', '25'], + ['26', '27', '28', '29', '30', '01', '02'], + ['03', '04', '05', '06', '07', '08', '09'] + ]); + + // No changes yet + $timeout.flush(2000); + expect($rootScope.date.getFullYear()).toEqual(2010); + expect($rootScope.date.getMonth()).toEqual(9 - 1); + expect($rootScope.date.getDate()).toEqual(30); + + expect(getOptions(true)).toEqual([ + ['29', '30', '31', '01', '02', '03', '04'], + ['05', '06', '07', '08', '09', '10', '11'], + ['12', '13', '14', '15', '16', '17', '18'], + ['19', '20', '21', '22', '23', '24', '25'], + ['26', '27', '28', '29', '30', '01', '02'], + ['03', '04', '05', '06', '07', '08', '09'] + ]); + + $timeout.flush(10000); + expect($rootScope.date.getFullYear()).toEqual(1980); + expect($rootScope.date.getMonth()).toEqual(2); + expect($rootScope.date.getDate()).toEqual(5); + + expect(getOptions(true)).toEqual([ + ['24', '25', '26', '27', '28', '29', '01'], + ['02', '03', '04', '05', '06', '07', '08'], + ['09', '10', '11', '12', '13', '14', '15'], + ['16', '17', '18', '19', '20', '21', '22'], + ['23', '24', '25', '26', '27', '28', '29'], + ['30', '31', '01', '02', '03', '04', '05'] + ]); + expectSelectedElement( 10 ); + }); + }); + + describe('works with ngModelOptions updateOn : "default"', function() { + var $timeout, wrapElement; + + beforeEach(inject(function(_$document_, _$sniffer_, _$timeout_) { + $document = _$document_; + $timeout = _$timeout_; + $rootScope.isopen = true; + $rootScope.date = new Date('2010-09-30T10:00:00.000Z'); + $rootScope.options = { + ngModelOptions: { + updateOn: 'default' + } + }; + wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('should close the popup and update the input when a day is clicked', function() { + clickOption(17); + assignElements(wrapElement); + expect(dropdownEl.length).toBe(0); + expect(inputEl.val()).toBe('2010-09-15'); + expect($rootScope.date).toEqual(new Date('2010-09-15T10:00:00.000Z')); + }); + }); + + describe('attribute `datepickerOptions`', function() { + describe('show-weeks', function() { + beforeEach(function() { + $rootScope.opts = { + showWeeks: false + }; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + }); + + it('hides week numbers based on variable', function() { + expect(getLabelsRow().find('th').length).toEqual(7); + var tr = element.find('tbody').find('tr'); + for (var i = 0; i < 5; i++) { + expect(tr.eq(i).find('td').length).toEqual(7); + } + }); + }); + + describe('init-date', function(){ + beforeEach(function() { + $rootScope.date = null; + $rootScope.opts = { + initDate: new Date('November 9, 1980') + }; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + }); + + it('does not alter the model', function() { + expect($rootScope.date).toBe(null); + }); + + it('shows the correct title', function() { + expect(getTitle()).toBe('November 1980'); + }); + }); + + describe('min-date', function() { + it('should be able to specify a min-date through options', function() { + $rootScope.opts = { + minDate: new Date('September 12, 2010'), + shortcutPropagation: 'dog' + }; + + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index < 14); + }); + + $rootScope.opts.minDate = new Date('September 13, 2010'); + $rootScope.$digest(); + buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index < 15); + }); + }); + }); + + describe('max-date', function() { + it('should be able to specify a max-date through options', function() { + $rootScope.opts = { + maxDate: new Date('September 25, 2010') + }; + + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index > 27); + }); + + $rootScope.opts.maxDate = new Date('September 15, 2010'); + $rootScope.$digest(); + buttons = getAllOptionsEl(); + angular.forEach(buttons, function(button, index) { + expect(angular.element(button).prop('disabled')).toBe(index > 17); + }); + }); + }); + + describe('min-mode', function() { + it('should be able to specify min-mode through options', function() { + $rootScope.opts = { + minMode: 'month' + }; + + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + + expect(getTitle()).toBe('2010'); + }); + }); + + describe('max-mode', function() { + it('should be able to specify max-mode through options', function() { + $rootScope.opts = { + maxMode: 'month' + }; + + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + + expect(getTitle()).toBe('September 2010'); + clickTitleButton(); + assignElements(wrapElement); + expect(getTitle()).toBe('2010'); + clickTitleButton(); + assignElements(wrapElement); + expect(getTitle()).toBe('2010'); + }); + }); + + describe('datepicker-mode', function() { + beforeEach(inject(function() { + $rootScope.date = new Date('August 11, 2013'); + $rootScope.opts = { + datepickerMode: 'month' + }; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('shows the correct title', function() { + expect(getTitle()).toBe('2013'); + }); + + it('updates binding', function() { + clickTitleButton(); + expect($rootScope.opts.datepickerMode).toBe('year'); + }); + }); + }); + + describe('option `init-date`', function() { + beforeEach(function() { + $rootScope.date = null; + $rootScope.options = { + initDate: new Date('November 9, 1980') + }; + }); + + describe('when initially set', function() { + beforeEach(function() { + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + }); + + it('does not alter the model', function() { + expect($rootScope.date).toBe(null); + }); + + it('shows the correct title', function() { + expect(getTitle()).toBe('November 1980'); + }); + }); + + describe('when modified before date selected.', function() { + beforeEach(function() { + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + + $rootScope.options.initDate = new Date('December 20, 1981'); + $rootScope.$digest(); + }); + + it('does not alter the model', function() { + expect($rootScope.date).toBe(null); + }); + + it('shows the correct title', function() { + expect(getTitle()).toBe('December 1981'); + }); + }); + + describe('when modified after date selected.', function() { + beforeEach(function() { + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + $rootScope.date = new Date('April 1, 1982'); + $rootScope.options.initDate = new Date('December 20, 1981'); + $rootScope.$digest(); + }); + + it('does not alter the model', function() { + expect($rootScope.date).toEqual(new Date('April 1, 1982')); + }); + + it('shows the correct title', function() { + expect(getTitle()).toBe('April 1982'); + }); + }); + }); + + describe('toggles programatically by `open` attribute', function() { + var wrapElement; + + beforeEach(inject(function() { + $rootScope.open = true; + wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('to display initially', function() { + expect(dropdownEl.length).toBe(1); + }); + + it('to close / open from scope variable', function() { + expect(dropdownEl.length).toBe(1); + $rootScope.open = false; + $rootScope.$digest(); + assignElements(wrapElement); + expect(dropdownEl.length).toBe(0); + + $rootScope.open = true; + $rootScope.$digest(); + assignElements(wrapElement); + expect(dropdownEl.length).toBe(1); + }); + }); + + describe('custom format', function() { + beforeEach(inject(function() { + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('to display the correct value in input', function() { + expect(inputEl.val()).toBe('30-September-2010'); + }); + + it('updates the input when a day is clicked', function() { + clickOption(17); + expect(inputEl.val()).toBe('15-September-2010'); + expect($rootScope.date).toEqual(new Date('September 15, 2010 15:30:00')); + }); + + it('updates the input correctly when model changes', function() { + $rootScope.date = new Date('January 10, 1983 10:00:00'); + $rootScope.$digest(); + expect(inputEl.val()).toBe('10-January-1983'); + }); + }); + + describe('custom format with time', function() { + beforeEach(inject(function() { + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('updates the model correctly when the input value changes', function() { + $rootScope.date = new Date(2015, 10, 24, 10, 0); + $rootScope.$digest(); + expect(inputEl.val()).toBe('Nov-24-2015 10:00 AM'); + + inputEl.val('Nov-24-2015 11:00 AM').trigger('input'); + $rootScope.$digest(); + expect($rootScope.date).toEqual(new Date(2015, 10, 24, 11, 0)); + }); + }); + + describe('custom format with optional leading zeroes', function() { + beforeEach(inject(function() { + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('to display the correct value in input', function() { + expect(inputEl.val()).toBe('30-09-2010'); + }); + + it('updates the input when a day is clicked', function() { + clickOption(10); + expect(inputEl.val()).toBe('08-09-2010'); + expect($rootScope.date).toEqual(new Date('September 8, 2010 15:30:00')); + }); + + it('updates the input correctly when model changes', function() { + $rootScope.date = new Date('December 25, 1983 10:00:00'); + $rootScope.$digest(); + expect(inputEl.val()).toBe('25-12-1983'); + }); + }); + + describe('dynamic custom format', function() { + beforeEach(inject(function() { + $rootScope.format = 'dd-MMMM-yyyy'; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('to display the correct value in input', function() { + expect(inputEl.val()).toBe('30-September-2010'); + }); + + it('updates the input when a day is clicked', function() { + clickOption(17); + expect(inputEl.val()).toBe('15-September-2010'); + expect($rootScope.date).toEqual(new Date('September 15, 2010 15:30:00')); + }); + + it('updates the input correctly when model changes', function() { + $rootScope.date = new Date('August 11, 2013 09:09:00'); + $rootScope.$digest(); + expect(inputEl.val()).toBe('11-August-2013'); + }); + + it('updates the input correctly when format changes', function() { + $rootScope.format = 'dd/MM/yyyy'; + $rootScope.$digest(); + expect(inputEl.val()).toBe('30/09/2010'); + }); + }); + + describe('format errors', function() { + var originalConfig = {}; + beforeEach(inject(function(uibDatepickerPopupConfig) { + angular.extend(originalConfig, uibDatepickerPopupConfig); + uibDatepickerPopupConfig.datepickerPopup = null; + })); + afterEach(inject(function(uibDatepickerPopupConfig) { + // return it to the original state + angular.extend(uibDatepickerPopupConfig, originalConfig); + })); + + it('should throw an error if there is no format', function() { + expect(function() { + $compile('')($rootScope); + }).toThrow(new Error('uibDatepickerPopup must have a date format specified.')); + }); + + it('should throw an error if the format changes to null without fallback', function() { + $rootScope.format = 'dd-MMMM-yyyy'; + $compile('')($rootScope); + $rootScope.$digest(); + + expect(function() { + $rootScope.format = null; + $rootScope.$digest(); + }).toThrow(new Error('uibDatepickerPopup must have a date format specified.')); + }); + + it('should thrown an error on date inputs with custom formats', function() { + expect(function() { + $compile('')($rootScope); + }).toThrow(new Error('HTML5 date input types do not support custom formats.')); + }); + }); + + describe('european format', function() { + it('dd.MM.yyyy', function() { + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + + changeInputValueTo(inputEl, '11.08.2013'); + expect($rootScope.date.getFullYear()).toEqual(2013); + expect($rootScope.date.getMonth()).toEqual(7); + expect($rootScope.date.getDate()).toEqual(11); + }); + }); + + describe('`close-on-date-selection` attribute', function() { + var wrapElement; + beforeEach(inject(function() { + $rootScope.close = false; + wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('does not close the dropdown when a day is clicked', function() { + clickOption(17); + assignElements(wrapElement); + expect(dropdownEl.length).toBe(1); + }); + }); + + describe('button bar', function() { + var buttons, buttonBarElement; + + function assignButtonBar() { + buttonBarElement = dropdownEl.find('li').eq(-1); + buttons = buttonBarElement.find('button'); + } + + describe('', function() { + var wrapElement; + + beforeEach(inject(function() { + $rootScope.isopen = true; + wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + assignButtonBar(); + })); + + it('should exist', function() { + expect(dropdownEl.length).toBe(1); + expect(dropdownEl.find('li').length).toBe(2); + }); + + it('should have three buttons', function() { + expect(buttons.length).toBe(3); + + expect(buttons.eq(0).text()).toBe('Today'); + expect(buttons.eq(1).text()).toBe('Clear'); + expect(buttons.eq(2).text()).toBe('Done'); + }); + + it('should have a button to set today date without altering time part', function() { + var today = new Date(); + buttons.eq(0).click(); + expect($rootScope.date.getFullYear()).toBe(today.getFullYear()); + expect($rootScope.date.getMonth()).toBe(today.getMonth()); + expect($rootScope.date.getDate()).toBe(today.getDate()); + + expect($rootScope.date.getHours()).toBe(15); + expect($rootScope.date.getMinutes()).toBe(30); + expect($rootScope.date.getSeconds()).toBe(0); + }); + + it('should have a button to set today date if blank', function() { + $rootScope.date = null; + $rootScope.$digest(); + + var today = new Date(); + buttons.eq(0).click(); + expect($rootScope.date.getFullYear()).toBe(today.getFullYear()); + expect($rootScope.date.getMonth()).toBe(today.getMonth()); + expect($rootScope.date.getDate()).toBe(today.getDate()); + + expect($rootScope.date.getHours()).toBe(0); + expect($rootScope.date.getMinutes()).toBe(0); + expect($rootScope.date.getSeconds()).toBe(0); + }); + + it('should have a button to clear value', function() { + buttons.eq(1).click(); + expect($rootScope.date).toBe(null); + }); + + it('should clear the previously selected date', function() { + $rootScope.date = new Date(); + $rootScope.$digest(); + buttons.eq(1).click(); + expect($rootScope.date).toBe(null); + }); + + it('should have a button to close calendar', function() { + buttons.eq(2).click(); + assignElements(wrapElement); + expect(dropdownEl.length).toBe(0); + }); + }); + + describe('customization', function() { + it('should change text from attributes', function() { + $rootScope.clearText = 'Null it!'; + $rootScope.close = 'Close'; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + assignButtonBar(); + + expect(buttons.eq(0).text()).toBe('Now'); + expect(buttons.eq(1).text()).toBe('Null it!'); + expect(buttons.eq(2).text()).toBe('CloseME'); + }); + + it('should disable today button if before min date', function() { + var date = new Date(); + date.setDate(new Date().getDate() + 1); + $rootScope.options = { + minDate: date + }; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + assignButtonBar(); + + expect(buttons.eq(0).prop('disabled')).toBe(true); + }); + + it('should disable today button if before min date, yyyy-MM-dd case', inject(function(dateFilter) { + var date = new Date(); + date.setDate(new Date().getDate() + 1); + var literalMinDate = dateFilter(date, 'yyyy-MM-dd'); + $rootScope.options = { + minDate: literalMinDate + }; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + assignButtonBar(); + + expect(buttons.eq(0).prop('disabled')).toBe(true); + })); + + it('should not disable any button if min date is null', function() { + $rootScope.options = { + minDate: null + }; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + assignButtonBar(); + + for (var i = 0; i < buttons.length; i++) { + expect(buttons.eq(i).prop('disabled')).toBe(false); + } + }); + + it('should disable today button if after max date', function() { + var date = new Date(); + date.setDate(new Date().getDate() - 2); + $rootScope.options = { + maxDate: date + }; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + assignButtonBar(); + + expect(buttons.eq(0).prop('disabled')).toBe(true); + }); + + it('should not disable any button if max date is null', function() { + $rootScope.options = { + maxDate: null + }; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + assignButtonBar(); + + for (var i = 0; i < buttons.length; i++) { + expect(buttons.eq(i).prop('disabled')).toBe(false); + } + }); + + it('should remove bar', function() { + $rootScope.showBar = false; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + expect(dropdownEl.find('li').length).toBe(1); + }); + + it('should hide weeks column on popup', function() { + $rootScope.options = { + showWeeks: false + }; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + + expect(getLabelsRow().find('th').length).toEqual(7); + var tr = element.find('tbody').find('tr'); + for (var i = 0; i < 5; i++) { + expect(tr.eq(i).find('td').length).toEqual(7); + } + }); + + it('should show weeks column on popup', function() { + $rootScope.options = { + showWeeks: true + }; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + + expect(getLabelsRow().find('th').eq(0)).not.toBeHidden(); + var tr = element.find('tbody').find('tr'); + for (var i = 0; i < 5; i++) { + expect(tr.eq(i).find('td').eq(0)).not.toBeHidden(); + } + }); + }); + + describe('`ng-change`', function() { + beforeEach(inject(function() { + $rootScope.changeHandler = jasmine.createSpy('changeHandler'); + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + assignButtonBar(); + })); + + it('should be called when `today` is clicked', function() { + buttons.eq(0).click(); + expect($rootScope.changeHandler).toHaveBeenCalled(); + }); + + it('should be called when `clear` is clicked', function() { + buttons.eq(1).click(); + expect($rootScope.changeHandler).toHaveBeenCalled(); + }); + + it('should not be called when `close` is clicked', function() { + buttons.eq(2).click(); + expect($rootScope.changeHandler).not.toHaveBeenCalled(); + }); + }); + }); + + describe('use with `ng-required` directive', function() { + describe('`ng-required is true`', function() { + beforeEach(inject(function() { + $rootScope.date = ''; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('should be invalid initially and when no date', function() { + expect(inputEl.hasClass('ng-invalid')).toBeTruthy(); + }); + + it('should be valid if model has been specified', function() { + $rootScope.date = new Date(); + $rootScope.$digest(); + expect(inputEl.hasClass('ng-valid')).toBeTruthy(); + }); + + it('should be valid if model value is a valid timestamp', function() { + $rootScope.date = Date.now(); + $rootScope.$digest(); + expect(inputEl.hasClass('ng-valid')).toBeTruthy(); + }); + }); + + describe('`ng-required is false`', function() { + beforeEach(inject(function() { + $rootScope.date = ''; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('should be valid initially and when no date', function() { + expect(inputEl.hasClass('ng-valid')).toBeTruthy(); + }); + }); + }); + + describe('use with `ng-change` directive', function() { + beforeEach(inject(function() { + $rootScope.changeHandler = jasmine.createSpy('changeHandler'); + $rootScope.date = new Date('09/16/2010'); + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('should not be called initially', function() { + expect($rootScope.changeHandler).not.toHaveBeenCalled(); + }); + + it('should be called when a day is clicked', function() { + clickOption(17); + expect($rootScope.changeHandler).toHaveBeenCalled(); + }); + + it('should not be called when model changes programatically', function() { + $rootScope.date = new Date(); + $rootScope.$digest(); + expect($rootScope.changeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('with disabled', function() { + var wrapElement; + + beforeEach(function() { + $rootScope.isOpen = false; + wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('should not open the popup', function() { + $rootScope.isOpen = true; + $rootScope.$digest(); + + expect($rootScope.isOpen).toBe(false); + expect(wrapElement.find('ul').length).toBe(0); + }); + }); + + describe('with ng-disabled', function() { + var wrapElement; + + beforeEach(function() { + $rootScope.disabled = false; + $rootScope.isOpen = false; + wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('should not open the popup when disabled', function() { + $rootScope.isOpen = true; + $rootScope.$digest(); + + expect($rootScope.isOpen).toBe(true); + expect(wrapElement.find('ul').length).toBe(1); + + $rootScope.isOpen = false; + $rootScope.$digest(); + + expect($rootScope.isOpen).toBe(false); + expect(wrapElement.find('ul').length).toBe(0); + + $rootScope.disabled = true; + $rootScope.isOpen = true; + $rootScope.$digest(); + + expect($rootScope.isOpen).toBe(false); + expect(wrapElement.find('ul').length).toBe(0); + + $rootScope.disabled = false; + $rootScope.isOpen = true; + $rootScope.$digest(); + + expect($rootScope.isOpen).toBe(true); + expect(wrapElement.find('ul').length).toBe(1); + }); + }); + + describe('with datepicker-popup-template-url', function() { + beforeEach(function() { + $rootScope.date = new Date(); + }); + + afterEach(function () { + $document.find('body').find('.dropdown-menu').remove(); + }); + + it('should allow custom templates for the popup', function() { + $templateCache.put('foo/bar.html', 'baz'); + + var elm = angular.element(''); + + $compile(elm)($rootScope); + $rootScope.$digest(); + + expect(elm.children().eq(1).html()).toBe('baz'); + }); + }); + + describe('with datepicker-template-url', function() { + beforeEach(function() { + $rootScope.date = new Date(); + }); + + afterEach(function() { + $document.find('body').find('.dropdown-menu').remove(); + }); + + it('should allow custom templates for the datepicker', function() { + $templateCache.put('foo/bar.html', 'baz'); + + var elm = angular.element(''); + + $compile(elm)($rootScope); + $rootScope.$digest(); + + var datepicker = elm.find('[uib-datepicker]'); + + expect(datepicker.html()).toBe('baz'); + }); + }); + + describe('with an append-to-body attribute', function() { + beforeEach(function() { + $rootScope.date = new Date(); + }); + + afterEach(function() { + $document.find('body').children().remove(); + }); + + it('should append to the body', function() { + var $body = $document.find('body'), + bodyLength = $body.children().length, + elm = angular.element( + '' + ); + $compile(elm)($rootScope); + $rootScope.$digest(); + + expect($body.children().length).toEqual(bodyLength + 1); + expect(elm.children().length).toEqual(1); + }); + + it('should be removed on scope destroy', function() { + var $body = $document.find('body'), + bodyLength = $body.children().length, + isolatedScope = $rootScope.$new(), + elm = angular.element( + '' + ); + $compile(elm)(isolatedScope); + isolatedScope.$digest(); + expect($body.children().length).toEqual(bodyLength + 1); + isolatedScope.$destroy(); + expect($body.children().length).toEqual(bodyLength); + }); + }); + + describe('with setting datepickerConfig.showWeeks to false', function() { + var originalConfig = {}; + beforeEach(inject(function(uibDatepickerConfig) { + angular.extend(originalConfig, uibDatepickerConfig); + uibDatepickerConfig.showWeeks = false; + + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + afterEach(inject(function(uibDatepickerConfig) { + // return it to the original state + angular.extend(uibDatepickerConfig, originalConfig); + })); + + it('changes initial visibility for weeks', function() { + expect(getLabelsRow().find('th').length).toEqual(7); + var tr = element.find('tbody').find('tr'); + for (var i = 0; i < 5; i++) { + expect(tr.eq(i).find('td').length).toEqual(7); + } + }); + }); + + describe('`datepicker-mode`', function() { + beforeEach(inject(function() { + $rootScope.date = new Date('August 11, 2013'); + $rootScope.options = { + datepickerMode: 'month' + }; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + it('shows the correct title', function() { + expect(getTitle()).toBe('2013'); + }); + + it('updates binding', function() { + clickTitleButton(); + expect($rootScope.options.datepickerMode).toBe('year'); + }); + }); + + describe('attribute `onOpenFocus`', function() { + beforeEach(function() { + $rootScope.date = null; + $rootScope.isopen = false; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + }); + + it('should remain focused on the input', function() { + var focused = true; + expect(dropdownEl.length).toBe(0); + + inputEl[0].focus(); + inputEl.on('blur', function() { + focused = false; + }); + $rootScope.isopen = true; + $rootScope.$digest(); + + expect(inputEl.parent().find('.dropdown-menu').length).toBe(1); + expect(focused).toBe(true); + }); + }); + + describe('altInputFormats', function() { + describe('datepickerPopupConfig.altInputFormats', function() { + var originalConfig = {}; + beforeEach(inject(function(uibDatepickerPopupConfig) { + $rootScope.date = new Date('November 9, 1980'); + angular.extend(originalConfig, uibDatepickerPopupConfig); + uibDatepickerPopupConfig.datepickerPopup = 'MM-dd-yyyy'; + uibDatepickerPopupConfig.altInputFormats = ['M!/d!/yyyy']; + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + })); + + afterEach(inject(function(uibDatepickerPopupConfig) { + // return it to the original state + angular.extend(uibDatepickerPopupConfig, originalConfig); + })); + + it('changes date format', function() { + changeInputValueTo(inputEl, '11/8/1980'); + + expect($rootScope.date.getFullYear()).toEqual(1980); + expect($rootScope.date.getMonth()).toEqual(10); + expect($rootScope.date.getDate()).toEqual(8); + }); + + it('changes the datepicker', function() { + expect(selectedElementIndex()).toEqual(14); + changeInputValueTo(inputEl, '11/8/1980'); + expect(selectedElementIndex()).toEqual(13); + }); + }); + + describe('attribute `alt-input-formats`', function() { + beforeEach(function() { + $rootScope.date = new Date('November 9, 1980'); + var wrapElement = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + }); + + it('should accept alternate input formats', function() { + changeInputValueTo(inputEl, '11/8/1980'); + + expect($rootScope.date.getFullYear()).toEqual(1980); + expect($rootScope.date.getMonth()).toEqual(10); + expect($rootScope.date.getDate()).toEqual(8); + }); + + it('changes the datepicker', function() { + expect(selectedElementIndex()).toEqual(14); + changeInputValueTo(inputEl, '11/8/1980'); + expect(selectedElementIndex()).toEqual(13); + }); + }); + }); + + describe('uibDatepickerConfig ngModelOptions', function() { + var inputEl, dropdownEl; + + function assignElements(wrapElement) { + inputEl = wrapElement.find('input'); + dropdownEl = wrapElement.find('ul'); + element = dropdownEl.find('table'); + } + + beforeEach(inject(function(uibDatepickerConfig) { + uibDatepickerConfig.ngModelOptions = { timezone: '+600' }; + $rootScope.date = new Date('2010-09-30T10:00:00.000Z'); + $rootScope.isopen = true; + })); + + afterEach(inject(function(uibDatepickerConfig) { + uibDatepickerConfig.ngModelOptions = {}; + })); + + describe('timezone', function() { + beforeEach(inject(function(uibDatepickerConfig) { + var wrapper = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapper); + })); + + it('interprets the date appropriately', function() { + expect(inputEl.val()).toBe('09/30/2010'); + }); + + it('updates the input when a day is clicked', function() { + clickOption(17); + expect(inputEl.val()).toBe('09/15/2010'); + expect($rootScope.date).toEqual(new Date('2010-09-15T10:00:00.000Z')); + }); + + it('shows the correct title', function() { + expect(getTitle()).toBe('September 2010'); + }); + }); + + it('timezone interprets init date appropriately', function() { + $rootScope.options = { + initDate: new Date('2010-09-30T23:00:00.000Z') + }; + $rootScope.date = null; + var wrapper = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapper); + + expect(getTitle()).toBe('October 2010'); + }); + + it('timezone interprets min date appropriately', function() { + $rootScope.options = { + minDate: new Date('2010-10-01T00:00:00.000Z') + }; + var wrapper = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapper); + + expect(getSelectedElement().prop('disabled')).toBe(true); + }); + }); + + describe('ng-model-options', function() { + describe('timezone', function() { + var inputEl, dropdownEl, $document, $sniffer, $timeout; + + function assignElements(wrapElement) { + inputEl = wrapElement.find('input'); + dropdownEl = wrapElement.find('ul'); + element = dropdownEl.find('table'); + } + + beforeEach(function() { + $rootScope.date = new Date('2010-09-30T10:00:00.000Z'); + $rootScope.options = { + ngModelOptions: { + timezone: '+600' + } + }; + $rootScope.isopen = true; + var wrapper = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapper); + }); + + it('interprets the date appropriately', function() { + expect(inputEl.val()).toBe('09/30/2010'); + }); + + it('has `selected` only the correct day', function() { + expectSelectedElement(32); + }); + + it('updates the input when a day is clicked', function() { + clickOption(17); + expect(inputEl.val()).toBe('09/15/2010'); + expect($rootScope.date).toEqual(new Date('2010-09-15T10:00:00.000Z')); + }); + }); + + describe('timezone HTML5 date input', function() { + var inputEl, dropdownEl, $document, $sniffer, $timeout; + + function assignElements(wrapElement) { + inputEl = wrapElement.find('input'); + dropdownEl = wrapElement.find('ul'); + element = dropdownEl.find('table'); + } + + beforeEach(function() { + $rootScope.date = new Date('2010-09-30T10:00:00.000Z'); + $rootScope.options = { + ngModelOptions: { + timezone: '+600' + } + }; + $rootScope.isopen = true; + var wrapper = $compile('')($rootScope); + $rootScope.$digest(); + assignElements(wrapper); + }); + + it('interprets the date appropriately', function() { + expect(inputEl.val()).toBe('2010-09-30'); + }); + + it('has `selected` only the correct day', function() { + expectSelectedElement(32); + }); + + it('updates the input when a day is clicked', function() { + clickOption(17); + expect(inputEl.val()).toBe('2010-09-15'); + expect($rootScope.date).toEqual(new Date('2010-09-15T10:00:00.000Z')); + }); + }); + }); +}); diff --git a/src/debounce/debounce.js b/src/debounce/debounce.js new file mode 100644 index 0000000000..28ff4f798a --- /dev/null +++ b/src/debounce/debounce.js @@ -0,0 +1,21 @@ +angular.module('ui.bootstrap.debounce', []) +/** + * A helper, internal service that debounces a function + */ + .factory('$$debounce', ['$timeout', function($timeout) { + return function(callback, debounceTime) { + var timeoutPromise; + + return function() { + var self = this; + var args = Array.prototype.slice.call(arguments); + if (timeoutPromise) { + $timeout.cancel(timeoutPromise); + } + + timeoutPromise = $timeout(function() { + callback.apply(self, args); + }, debounceTime); + }; + }; + }]); diff --git a/src/debounce/index.js b/src/debounce/index.js new file mode 100644 index 0000000000..a398c57165 --- /dev/null +++ b/src/debounce/index.js @@ -0,0 +1,7 @@ +require('./debounce'); + +var MODULE_NAME = 'ui.bootstrap.module.debounce'; + +angular.module(MODULE_NAME, ['ui.bootstrap.debounce']); + +module.exports = MODULE_NAME; diff --git a/src/debounce/test/debounce.spec.js b/src/debounce/test/debounce.spec.js new file mode 100644 index 0000000000..b220350299 --- /dev/null +++ b/src/debounce/test/debounce.spec.js @@ -0,0 +1,48 @@ +describe('$$debounce', function() { + var $$debounce, $timeout, debouncedFunction, i, args; + + beforeEach(module('ui.bootstrap.debounce')); + beforeEach(inject(function(_$$debounce_, _$timeout_) { + $$debounce = _$$debounce_; + $timeout = _$timeout_; + i = 0; + debouncedFunction = $$debounce(function() { + args = Array.prototype.slice.call(arguments); + i++; + }, 100); + })); + + it('should function like a $timeout when called once during timeout', function() { + debouncedFunction(); + $timeout.flush(50); + + expect(i).toBe(0); + + $timeout.flush(50); + + expect(i).toBe(1); + }); + + it('should only execute 100ms after last call when called twice', function() { + debouncedFunction(); + $timeout.flush(50); + + expect(i).toBe(0); + + debouncedFunction(); + $timeout.flush(50); + + expect(i).toBe(0); + + $timeout.flush(50); + + expect(i).toBe(1); + }); + + it('should properly pass arguments to debounced function', function() { + debouncedFunction(1, 2, 3); + $timeout.flush(100); + + expect(args).toEqual([1, 2, 3]); + }); +}); diff --git a/src/dialog/README.md b/src/dialog/README.md deleted file mode 100644 index d1c16236df..0000000000 --- a/src/dialog/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# $dialogProvider (service in ui.bootstrap) - -## Description - -Used for configuring global options for dialogs. - -### Methods - -#### `options(opts)` - -Sets the default global options for your application. Options can be overridden when opening dialogs. Available options are: - -* `backdrop`: a boolean value indicating whether a backdrop should be used or not, defaults to true -* `dialogClass`: the css class for the modal div, defaults to 'modal' -* `backdropClass`: the css class for the backdrop, defaults to 'modal-backdrop' -* `transitionClass`: the css class that applies transitions to the modal and backdrop, defaults to 'fade' -* `triggerClass`: the css class that triggers the transitions, defaults to 'in' -* `dialogOpenClass`: the css class that is added to body when dialog is opened, defaults to 'modal-open' -* `resolve`: members that will be resolved and passed to the controller as locals -* `controller`: the controller to associate with the included partial view -* `backdropFade`: a boolean value indicating whether the backdrop should fade in and out using a CSS transition, defaults to false -* `dialogFade`: a boolean value indicating whether the modal should fade in and out using a CSS transition, defaults to false -* `keyboard`: indicates whether the dialog should be closable by hitting the ESC key, defaults to true -* `backdropClick`: indicates whether the dialog should be closable by clicking the backdrop area, defaults to true -* `template`: the template for dialog -* `templateUrl`: path to the template for dialog - -Example: - - var app = angular.module('App', ['ui.bootstrap.dialog'] , function($dialogProvider){ - $dialogProvider.options({backdropClick: false, dialogFade: true}); - }); - -# $dialog service - -## Description - -Allows you to open dialogs from within your controller. - -### Methods - -#### `dialog([templateUrl[, controller]])` - -Creates a new dialog, optionally setting the `templateUrl`, and `controller` options. - -Example: - - app.controller('MainCtrl', function($dialog, $scope) { - $scope.openItemEditor = function(item){ - var d = $dialog.dialog({dialogFade: false, resolve: {item: function(){ return angular.copy(item); } }}); - d.open('dialogs/item-editor.html', 'EditItemController'); - }; - }); - - // note that the resolved item as well as the dialog are injected in the dialog's controller - app.controller('EditItemController', ['$scope', 'dialog', 'item', function($scope, dialog, item){ - $scope.item = item; - $scope.submit = function(){ - dialog.close('ok'); - }; - }]); - -#### `messageBox(title, message, buttons)` - -Opens a message box with the specified `title`, `message` and a series of `buttons` can be provided, every button can specify: - -* `label`: the label of the button -* `result`: the result used to invoke the close method of the dialog -* `cssClass`: optional, the CSS class (e.g. btn-primary) to apply to the button - -Example: - - app.controller('MainCtrl', function($dialog, $scope) { - $scope.deleteItem = function(item){ - var msgbox = $dialog.messageBox('Delete Item', 'Are you sure?', [{label:'Yes, I\'m sure', result: 'yes'},{label:'Nope', result: 'no'}]); - msgbox.open().then(function(result){ - if(result === 'yes') {deleteItem(item);} - }); - }; - }); - -## Dialog class - -The dialog object returned by the `$dialog` service methods `open` and `message`. - -### Methods - -#### `open` - -(Re)Opens the dialog and returns a promise. - -#### `close([result])` - -Closes the dialog. Optionally a result can be specified. The result is used to resolve the promise returned by the `open` method. - -#### `isOpen` - -Returns true if the dialog is shown, else returns false. - diff --git a/src/dialog/dialog.js b/src/dialog/dialog.js deleted file mode 100644 index 0e0413963c..0000000000 --- a/src/dialog/dialog.js +++ /dev/null @@ -1,280 +0,0 @@ -// The `$dialogProvider` can be used to configure global defaults for your -// `$dialog` service. -var dialogModule = angular.module('ui.bootstrap.dialog', ['ui.bootstrap.transition']); - -dialogModule.controller('MessageBoxController', ['$scope', 'dialog', 'model', function($scope, dialog, model){ - $scope.title = model.title; - $scope.message = model.message; - $scope.buttons = model.buttons; - $scope.close = function(res){ - dialog.close(res); - }; -}]); - -dialogModule.provider("$dialog", function(){ - - // The default options for all dialogs. - var defaults = { - backdrop: true, - dialogClass: 'modal', - backdropClass: 'modal-backdrop', - transitionClass: 'fade', - triggerClass: 'in', - resolve:{}, - backdropFade: false, - dialogFade:false, - keyboard: true, // close with esc key - backdropClick: true // only in conjunction with backdrop=true - /* other options: template, templateUrl, controller */ - }; - - var globalOptions = {}; - - var activeBackdrops = {value : 0}; - - // The `options({})` allows global configuration of all dialogs in the application. - // - // var app = angular.module('App', ['ui.bootstrap.dialog'], function($dialogProvider){ - // // don't close dialog when backdrop is clicked by default - // $dialogProvider.options({backdropClick: false}); - // }); - this.options = function(value){ - globalOptions = value; - }; - - // Returns the actual `$dialog` service that is injected in controllers - this.$get = ["$http", "$document", "$compile", "$rootScope", "$controller", "$templateCache", "$q", "$transition", "$injector", - function ($http, $document, $compile, $rootScope, $controller, $templateCache, $q, $transition, $injector) { - - var body = $document.find('body'); - - function createElement(clazz) { - var el = angular.element(""); - el.addClass(clazz); - return el; - } - - // The `Dialog` class represents a modal dialog. The dialog class can be invoked by providing an options object - // containing at lest template or templateUrl and controller: - // - // var d = new Dialog({templateUrl: 'foo.html', controller: 'BarController'}); - // - // Dialogs can also be created using templateUrl and controller as distinct arguments: - // - // var d = new Dialog('path/to/dialog.html', MyDialogController); - function Dialog(opts) { - - var self = this, options = this.options = angular.extend({}, defaults, globalOptions, opts); - this._open = false; - - this.backdropEl = createElement(options.backdropClass); - if(options.backdropFade){ - this.backdropEl.addClass(options.transitionClass); - this.backdropEl.removeClass(options.triggerClass); - } - - this.modalEl = createElement(options.dialogClass); - if(options.dialogFade){ - this.modalEl.addClass(options.transitionClass); - this.modalEl.removeClass(options.triggerClass); - } - - this.handledEscapeKey = function(e) { - if (e.which === 27) { - self.close(); - e.preventDefault(); - self.$scope.$apply(); - } - }; - - this.handleBackDropClick = function(e) { - self.close(); - e.preventDefault(); - self.$scope.$apply(); - }; - } - - // The `isOpen()` method returns wether the dialog is currently visible. - Dialog.prototype.isOpen = function(){ - return this._open; - }; - - // The `open(templateUrl, controller)` method opens the dialog. - // Use the `templateUrl` and `controller` arguments if specifying them at dialog creation time is not desired. - Dialog.prototype.open = function(templateUrl, controller){ - var self = this, options = this.options; - - if(templateUrl){ - options.templateUrl = templateUrl; - } - if(controller){ - options.controller = controller; - } - - if(!(options.template || options.templateUrl)) { - throw new Error('Dialog.open expected template or templateUrl, neither found. Use options or open method to specify them.'); - } - - this._loadResolves().then(function(locals) { - var $scope = locals.$scope = self.$scope = locals.$scope ? locals.$scope : $rootScope.$new(); - - self.modalEl.html(locals.$template); - - if (self.options.controller) { - var ctrl = $controller(self.options.controller, locals); - self.modalEl.children().data('ngControllerController', ctrl); - } - - $compile(self.modalEl)($scope); - self._addElementsToDom(); - - // trigger tranisitions - setTimeout(function(){ - if(self.options.dialogFade){ self.modalEl.addClass(self.options.triggerClass); } - if(self.options.backdropFade){ self.backdropEl.addClass(self.options.triggerClass); } - }); - - self._bindEvents(); - }); - - this.deferred = $q.defer(); - return this.deferred.promise; - }; - - // closes the dialog and resolves the promise returned by the `open` method with the specified result. - Dialog.prototype.close = function(result){ - var self = this; - var fadingElements = this._getFadingElements(); - - if(fadingElements.length > 0){ - for (var i = fadingElements.length - 1; i >= 0; i--) { - $transition(fadingElements[i], removeTriggerClass).then(onCloseComplete); - } - return; - } - - this._onCloseComplete(result); - - function removeTriggerClass(el){ - el.removeClass(self.options.triggerClass); - } - - function onCloseComplete(){ - if(self._open){ - self._onCloseComplete(result); - } - } - }; - - Dialog.prototype._getFadingElements = function(){ - var elements = []; - if(this.options.dialogFade){ - elements.push(this.modalEl); - } - if(this.options.backdropFade){ - elements.push(this.backdropEl); - } - - return elements; - }; - - Dialog.prototype._bindEvents = function() { - if(this.options.keyboard){ body.bind('keydown', this.handledEscapeKey); } - if(this.options.backdrop && this.options.backdropClick){ this.backdropEl.bind('click', this.handleBackDropClick); } - }; - - Dialog.prototype._unbindEvents = function() { - if(this.options.keyboard){ body.unbind('keydown', this.handledEscapeKey); } - if(this.options.backdrop && this.options.backdropClick){ this.backdropEl.unbind('click', this.handleBackDropClick); } - }; - - Dialog.prototype._onCloseComplete = function(result) { - this._removeElementsFromDom(); - this._unbindEvents(); - - this.deferred.resolve(result); - }; - - Dialog.prototype._addElementsToDom = function(){ - body.append(this.modalEl); - - if(this.options.backdrop) { - if (activeBackdrops.value === 0) { - body.append(this.backdropEl); - } - activeBackdrops.value++; - } - - this._open = true; - }; - - Dialog.prototype._removeElementsFromDom = function(){ - this.modalEl.remove(); - - if(this.options.backdrop) { - activeBackdrops.value--; - if (activeBackdrops.value === 0) { - this.backdropEl.remove(); - } - } - this._open = false; - }; - - // Loads all `options.resolve` members to be used as locals for the controller associated with the dialog. - Dialog.prototype._loadResolves = function(){ - var values = [], keys = [], templatePromise, self = this; - - if (this.options.template) { - templatePromise = $q.when(this.options.template); - } else if (this.options.templateUrl) { - templatePromise = $http.get(this.options.templateUrl, {cache:$templateCache}) - .then(function(response) { return response.data; }); - } - - angular.forEach(this.options.resolve || [], function(value, key) { - keys.push(key); - values.push(angular.isString(value) ? $injector.get(value) : $injector.invoke(value)); - }); - - keys.push('$template'); - values.push(templatePromise); - - return $q.all(values).then(function(values) { - var locals = {}; - angular.forEach(values, function(value, index) { - locals[keys[index]] = value; - }); - locals.dialog = self; - return locals; - }); - }; - - // The actual `$dialog` service that is injected in controllers. - return { - // Creates a new `Dialog` with the specified options. - dialog: function(opts){ - return new Dialog(opts); - }, - // creates a new `Dialog` tied to the default message box template and controller. - // - // Arguments `title` and `message` are rendered in the modal header and body sections respectively. - // The `buttons` array holds an object with the following members for each button to include in the - // modal footer section: - // - // * `result`: the result to pass to the `close` method of the dialog when the button is clicked - // * `label`: the label of the button - // * `cssClass`: additional css class(es) to apply to the button for styling - messageBox: function(title, message, buttons){ - return new Dialog({templateUrl: 'template/dialog/message.html', controller: 'MessageBoxController', resolve: - {model: function() { - return { - title: title, - message: message, - buttons: buttons - }; - } - }}); - } - }; - }]; -}); diff --git a/src/dialog/docs/demo.html b/src/dialog/docs/demo.html deleted file mode 100644 index b4e3cc8dcb..0000000000 --- a/src/dialog/docs/demo.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - Show backdrop - Fade modal dialog - Fade Backdrop - - Close on Escape - Close on backdrop click - - - Change options at will and press the open dialog button below! - Open Dialog - - Alternatively open a simple message box: - Open Message Box - - - diff --git a/src/dialog/docs/demo.js b/src/dialog/docs/demo.js deleted file mode 100644 index 6b9756c93b..0000000000 --- a/src/dialog/docs/demo.js +++ /dev/null @@ -1,50 +0,0 @@ -function DialogDemoCtrl($scope, $dialog){ - - // Inlined template for demo - var t = ''+ - 'This is the title'+ - ''+ - ''+ - 'Enter a value to pass to close as the result: '+ - ''+ - ''; - - $scope.opts = { - backdrop: true, - keyboard: true, - backdropClick: true, - template: t, // OR: templateUrl: 'path/to/view.html', - controller: 'TestDialogController' - }; - - $scope.openDialog = function(){ - var d = $dialog.dialog($scope.opts); - d.open().then(function(result){ - if(result) - { - alert('dialog closed with result: ' + result); - } - }); - }; - - $scope.openMessageBox = function(){ - var title = 'This is a message box'; - var msg = 'This is the content of the message box'; - var btns = [{result:'cancel', label: 'Cancel'}, {result:'ok', label: 'OK', cssClass: 'btn-primary'}]; - - $dialog.messageBox(title, msg, btns) - .open() - .then(function(result){ - alert('dialog closed with result: ' + result); - }); - }; -} - -// the dialog is injected in the specified controller -function TestDialogController($scope, dialog){ - $scope.close = function(result){ - dialog.close(result); - }; -} diff --git a/src/dialog/docs/readme.md b/src/dialog/docs/readme.md deleted file mode 100644 index e45f967a85..0000000000 --- a/src/dialog/docs/readme.md +++ /dev/null @@ -1,6 +0,0 @@ -The `$dialog` service allows you to open dialogs and message boxes from within your controllers. Very useful in case loading dialog content in the DOM up-front is tedious or not desired. - -Creating custom dialogs is straightforward: create a partial view, its controller and reference them when using the service. -Generic message boxes (title, message and buttons) are also provided for your convenience. - -For more information, see the [dialog readme](https://github.com/angular-ui/bootstrap/blob/master/src/dialog/README.md) on github. \ No newline at end of file diff --git a/src/dialog/test/dialog.spec.js b/src/dialog/test/dialog.spec.js deleted file mode 100644 index 1998bf0466..0000000000 --- a/src/dialog/test/dialog.spec.js +++ /dev/null @@ -1,309 +0,0 @@ -describe('Given ui.bootstrap.dialog', function(){ - - var $document, $compile, $scope, $rootScope, $dialog, q, provider; - var template = 'I\'m a template '; - - beforeEach(module('ui.bootstrap.dialog')); - beforeEach(module('template/dialog/message.html')); - - beforeEach(function(){ - module(function($dialogProvider){ - provider = $dialogProvider; - }); - inject(function(_$document_, _$compile_, _$rootScope_, _$dialog_, _$q_){ - $document = _$document_; - $compile = _$compile_; - $scope = _$rootScope_.$new(); - $rootScope = _$rootScope_; - $dialog = _$dialog_; - q = _$q_; - }); - }); - - // clean-up after ourselves - afterEach(function(){ - closeDialog(); - clearGlobalOptions(); - }); - - it('provider service should be injected', function(){ - expect(provider).toBeDefined(); - }); - - it('dialog service should be injected', function(){ - expect($dialog).toBeDefined(); - }); - - var dialog; - - var createDialog = function(opts){ - dialog = $dialog.dialog(opts); - }; - - var openDialog = function(templateUrl, controller){ - dialog.open(templateUrl, controller); - $scope.$apply(); - }; - - var closeDialog = function(result){ - if(dialog){ - dialog.close(result); - $rootScope.$apply(); - } - }; - - var setGlobalOptions = function(opts){ - provider.options(opts); - }; - - var clearGlobalOptions = function(){ - provider.options({}); - }; - - - var dialogShouldBeClosed = function(){ - it('should not include a backdrop in the DOM', function(){ - expect($document.find('body > div.modal-backdrop').length).toBe(0); - }); - - it('should not include the modal in the DOM', function(){ - expect($document.find('body > div.modal').length).toBe(0); - }); - - it('should return false for isOpen()', function(){ - expect(dialog.isOpen()).toBeFalsy(); - }); - }; - - var dialogShouldBeOpen = function(){ - it('the dialog.isOpen() should be true', function(){ - expect(dialog.isOpen()).toBe(true); - }); - - it('the backdrop should be displayed', function(){ - expect($document.find('body > div.modal-backdrop').css('display')).toBe('block'); - }); - - it('the modal should be displayed', function(){ - expect($document.find('body > div.modal').css('display')).toBe('block'); - }); - }; - - describe('Given global option', function(){ - - var useDialogWithGlobalOption = function(opts){ - beforeEach(function(){ - setGlobalOptions(opts); - createDialog({template:template}); - openDialog(); - }); - }; - - describe('backdrop:false', function(){ - useDialogWithGlobalOption({backdrop: false}); - - it('should not include a backdrop in the DOM', function(){ - expect($document.find('body > div.modal-backdrop').length).toBe(0); - }); - - it('should include the modal in the DOM', function(){ - expect($document.find('body > div.modal').length).toBe(1); - }); - }); - - describe('dialogClass:foo, backdropClass:bar', function(){ - useDialogWithGlobalOption({dialogClass: 'foo', backdropClass: 'bar'}); - - it('backdrop class should be changed', function(){ - expect($document.find('body > div.bar').length).toBe(1); - }); - - it('the modal should be change', function(){ - expect($document.find('body > div.foo').length).toBe(1); - }); - }); - - /* - describe('dialogFade:true, backdropFade:true', function(){ - useDialogWithGlobalOption({dialogFade:true, backdropFade:true}); - - it('backdrop class should be changed', function(){ - expect($document.find('body > div.modal.fade').length).toBe(1); - }); - - it('the modal should be change', function(){ - expect($document.find('body > div.modal-backdrop.fade').length).toBe(1); - }); - });*/ - }); - - describe('Opening a dialog', function(){ - - beforeEach(function(){ - createDialog({template:template}); - openDialog(); - }); - - dialogShouldBeOpen(); - }); - - describe('When opening a dialog with a controller', function(){ - - var resolvedDialog; - function Ctrl(dialog){ - resolvedDialog = dialog; - } - - beforeEach(function(){ - createDialog({template:template, controller: Ctrl}); - openDialog(); - }); - - dialogShouldBeOpen(); - - it('should inject the current dialog in the controller', function(){ - expect(resolvedDialog).toBe(dialog); - }); - }); - - describe('When opening a dialog with resolves', function(){ - - var resolvedFoo, resolvedBar, deferred, resolveObj; - function Ctrl(foo, bar){ - resolvedFoo = foo; - resolvedBar = bar; - } - - beforeEach(function(){ - deferred = q.defer(); - resolveObj = { - foo: function(){return 'foo';}, - bar: function(){return deferred.promise;} - }; - - createDialog({template:template, resolve: resolveObj, controller: Ctrl}); - deferred.resolve('bar'); - openDialog(); - }); - - dialogShouldBeOpen(); - - it('should inject resolved promises in the controller', function(){ - expect(resolvedBar).toBe('bar'); - }); - - it('should inject simple values in the controller', function(){ - expect(resolvedFoo).toBe('foo'); - }); - }); - - describe('when closing a dialog', function(){ - - beforeEach(function(){ - createDialog({template:template}); - openDialog(); - closeDialog(); - }); - - dialogShouldBeClosed(); - - describe('When opening it again', function(){ - beforeEach(function(){ - expect($document.find('body > div.modal-backdrop').length).toBe(0); - openDialog(); - }); - - dialogShouldBeOpen(); - }); - }); - - describe('when closing a dialog with a result', function(){ - var res; - beforeEach(function(){ - createDialog({template:template}); - dialog.open().then(function(result){ res = result; }); - $rootScope.$apply(); - - closeDialog('the result'); - }); - - dialogShouldBeClosed(); - - it('should call the then method with the specified result', function(){ - expect(res).toBe('the result'); - }); - }); - - describe('when closing a dialog with backdrop click', function(){ - beforeEach(function(){ - createDialog({template:'foo'}); - openDialog(); - $document.find('body > div.modal-backdrop').click(); - }); - - dialogShouldBeClosed(); - }); - - describe('when closing a dialog with escape key', function(){ - beforeEach(function(){ - createDialog({template:'foo'}); - openDialog(); - var e = $.Event('keydown'); - e.which = 27; - $document.find('body').trigger(e); - }); - - dialogShouldBeClosed(); - }); - - describe('When opening a dialog with a template url', function(){ - - beforeEach(function(){ - createDialog({templateUrl:'template/dialog/message.html'}); - openDialog(); - }); - - dialogShouldBeOpen(); - }); - - describe('When opening a dialog by passing template and controller to open method', function(){ - - var controllerIsCreated; - function Controller($scope, dialog){ - controllerIsCreated = true; - } - - beforeEach(function(){ - createDialog({templateUrl:'this/will/not/be/used.html', controller: 'foo'}); - openDialog('template/dialog/message.html', Controller); - }); - - dialogShouldBeOpen(); - - it('should used the specified controller', function(){ - expect(controllerIsCreated).toBe(true); - }); - - it('should use the specified template', function(){ - expect($document.find('body > div.modal > div.modal-header').length).toBe(1); - }); - }); - - describe('when opening it with a template containing white-space', function(){ - - var controllerIsCreated; - function Controller($scope, dialog){ - controllerIsCreated = true; - } - - beforeEach(function(){ - createDialog({ - template:' Has whitespace that IE8 does not like assigning data() to ', - controller: Controller - }); - openDialog(); - }); - - dialogShouldBeOpen(); - }); -}); diff --git a/src/dropdown/docs/demo.html b/src/dropdown/docs/demo.html new file mode 100644 index 0000000000..0bb9586689 --- /dev/null +++ b/src/dropdown/docs/demo.html @@ -0,0 +1,144 @@ + + + + + + Click me for a dropdown, yo! + + + + {{choice}} + + + + + + + + Button dropdown + + + Action + Another action + Something else here + + Separated link + + + + + + Action + + + Split button! + + + Action + Another action + Something else here + + Separated link + + + + + + + Dropdown on Body + + + Action + Another action + Something else here + + Separated link + + + + + + + Dropdown using template + + + + + + + + Toggle button dropdown + Enable/Disable + + + + + + + Dropdown with keyboard navigation + + + Action + Another action + Something else here + + Separated link + + + + + + append-to vs. append-to-body vs. inline example + + + + + + Dropdown in Container + + + Action + Another action + Something else here + + Separated link + + + + + Dropdown on Body + + + Action + Another action + Something else here + + Separated link + + + + + Inline Dropdown + + + Action + Another action + Something else here + + Separated link + + + + + + + + diff --git a/src/dropdown/docs/demo.js b/src/dropdown/docs/demo.js new file mode 100644 index 0000000000..955746fae1 --- /dev/null +++ b/src/dropdown/docs/demo.js @@ -0,0 +1,23 @@ +angular.module('ui.bootstrap.demo').controller('DropdownCtrl', function ($scope, $log) { + $scope.items = [ + 'The first choice!', + 'And another choice for you.', + 'but wait! A third!' + ]; + + $scope.status = { + isopen: false + }; + + $scope.toggled = function(open) { + $log.log('Dropdown is now: ', open); + }; + + $scope.toggleDropdown = function($event) { + $event.preventDefault(); + $event.stopPropagation(); + $scope.status.isopen = !$scope.status.isopen; + }; + + $scope.appendToEl = angular.element(document.querySelector('#dropdown-long-content')); +}); diff --git a/src/dropdown/docs/readme.md b/src/dropdown/docs/readme.md new file mode 100644 index 0000000000..3026673ce2 --- /dev/null +++ b/src/dropdown/docs/readme.md @@ -0,0 +1,63 @@ +Dropdown is a simple directive which will toggle a dropdown menu on click or programmatically. + +This directive is composed by three parts: + +* `uib-dropdown` which transforms a node into a dropdown. +* `uib-dropdown-toggle` which allows the dropdown to be toggled via click. This directive is optional. +* `uib-dropdown-menu` which transforms a node into the popup menu. + +Each of these parts need to be used as attribute directives. + +### uib-dropdown settings + +* `auto-close` + _(Default: `always`)_ - + Controls the behavior of the menu when clicked. + * `always` - Automatically closes the dropdown when any of its elements is clicked. + * `disabled` - Disables the auto close. You can control it manually with `is-open`. It still gets closed if the toggle is clicked, `esc` is pressed or another dropdown is open. + * `outsideClick` - Closes the dropdown automatically only when the user clicks any element outside the dropdown. + +* `dropdown-append-to` + $ + _(Default: `null`)_ - + Appends the inner dropdown-menu to an arbitrary DOM element. + +* `dropdown-append-to-body` + B + _(Default: `false`)_ - + Appends the inner dropdown-menu to the body element if the attribute is present without a value, or with a non `false` value. + +* `is-open` + $ + + _(Default: `false`)_ - + Defines whether or not the dropdown-menu is open. The `uib-dropdown-toggle` will toggle this attribute on click. + +* `keyboard-nav`: + B + _(Default: `false`)_ - + Enables navigation of dropdown list elements with the arrow keys. + +* `on-toggle(open)` + $ - + An optional expression called when the dropdown menu is opened or closed. + +### uib-dropdown-menu settings + +* `template-url` + _(Default: `none`)_ - + You may specify a template for the dropdown menu. Check the demos for an example. + +### Additional settings `uibDropdownConfig` + +* `appendToOpenClass` + _(Default: `uib-dropdown-open`)_ - + Class to apply when the dropdown is open and appended to a different DOM element. + +* `openClass` + _(Default: `open`)_ - + Class to apply when the dropdown is open. + +### Known issues + +For usage with ngTouch, it is recommended to use the programmatic `is-open` trigger with ng-click - this is due to ngTouch decorating ng-click to prevent propagation of the event. diff --git a/src/dropdown/dropdown.js b/src/dropdown/dropdown.js new file mode 100644 index 0000000000..2560e282e8 --- /dev/null +++ b/src/dropdown/dropdown.js @@ -0,0 +1,440 @@ +angular.module('ui.bootstrap.dropdown', ['ui.bootstrap.multiMap', 'ui.bootstrap.position']) + +.constant('uibDropdownConfig', { + appendToOpenClass: 'uib-dropdown-open', + openClass: 'open' +}) + +.service('uibDropdownService', ['$document', '$rootScope', '$$multiMap', function($document, $rootScope, $$multiMap) { + var openScope = null; + var openedContainers = $$multiMap.createNew(); + + this.isOnlyOpen = function(dropdownScope, appendTo) { + var openedDropdowns = openedContainers.get(appendTo); + if (openedDropdowns) { + var openDropdown = openedDropdowns.reduce(function(toClose, dropdown) { + if (dropdown.scope === dropdownScope) { + return dropdown; + } + + return toClose; + }, {}); + if (openDropdown) { + return openedDropdowns.length === 1; + } + } + + return false; + }; + + this.open = function(dropdownScope, element, appendTo) { + if (!openScope) { + $document.on('click', closeDropdown); + } + + if (openScope && openScope !== dropdownScope) { + openScope.isOpen = false; + } + + openScope = dropdownScope; + + if (!appendTo) { + return; + } + + var openedDropdowns = openedContainers.get(appendTo); + if (openedDropdowns) { + var openedScopes = openedDropdowns.map(function(dropdown) { + return dropdown.scope; + }); + if (openedScopes.indexOf(dropdownScope) === -1) { + openedContainers.put(appendTo, { + scope: dropdownScope + }); + } + } else { + openedContainers.put(appendTo, { + scope: dropdownScope + }); + } + }; + + this.close = function(dropdownScope, element, appendTo) { + if (openScope === dropdownScope) { + $document.off('click', closeDropdown); + $document.off('keydown', this.keybindFilter); + openScope = null; + } + + if (!appendTo) { + return; + } + + var openedDropdowns = openedContainers.get(appendTo); + if (openedDropdowns) { + var dropdownToClose = openedDropdowns.reduce(function(toClose, dropdown) { + if (dropdown.scope === dropdownScope) { + return dropdown; + } + + return toClose; + }, {}); + if (dropdownToClose) { + openedContainers.remove(appendTo, dropdownToClose); + } + } + }; + + var closeDropdown = function(evt) { + // This method may still be called during the same mouse event that + // unbound this event handler. So check openScope before proceeding. + if (!openScope || !openScope.isOpen) { return; } + + if (evt && openScope.getAutoClose() === 'disabled') { return; } + + if (evt && evt.which === 3) { return; } + + var toggleElement = openScope.getToggleElement(); + if (evt && toggleElement && toggleElement[0].contains(evt.target)) { + return; + } + + var dropdownElement = openScope.getDropdownElement(); + if (evt && openScope.getAutoClose() === 'outsideClick' && + dropdownElement && dropdownElement[0].contains(evt.target)) { + return; + } + + openScope.focusToggleElement(); + openScope.isOpen = false; + + if (!$rootScope.$$phase) { + openScope.$apply(); + } + }; + + this.keybindFilter = function(evt) { + if (!openScope) { + // see this.close as ESC could have been pressed which kills the scope so we can not proceed + return; + } + + var dropdownElement = openScope.getDropdownElement(); + var toggleElement = openScope.getToggleElement(); + var dropdownElementTargeted = dropdownElement && dropdownElement[0].contains(evt.target); + var toggleElementTargeted = toggleElement && toggleElement[0].contains(evt.target); + if (evt.which === 27) { + evt.stopPropagation(); + openScope.focusToggleElement(); + closeDropdown(); + } else if (openScope.isKeynavEnabled() && [38, 40].indexOf(evt.which) !== -1 && openScope.isOpen && (dropdownElementTargeted || toggleElementTargeted)) { + evt.preventDefault(); + evt.stopPropagation(); + openScope.focusDropdownEntry(evt.which); + } + }; +}]) + +.controller('UibDropdownController', ['$scope', '$element', '$attrs', '$parse', 'uibDropdownConfig', 'uibDropdownService', '$animate', '$uibPosition', '$document', '$compile', '$templateRequest', function($scope, $element, $attrs, $parse, dropdownConfig, uibDropdownService, $animate, $position, $document, $compile, $templateRequest) { + var self = this, + scope = $scope.$new(), // create a child scope so we are not polluting original one + templateScope, + appendToOpenClass = dropdownConfig.appendToOpenClass, + openClass = dropdownConfig.openClass, + getIsOpen, + setIsOpen = angular.noop, + toggleInvoker = $attrs.onToggle ? $parse($attrs.onToggle) : angular.noop, + keynavEnabled = false, + selectedOption = null, + body = $document.find('body'); + + $element.addClass('dropdown'); + + this.init = function() { + if ($attrs.isOpen) { + getIsOpen = $parse($attrs.isOpen); + setIsOpen = getIsOpen.assign; + + $scope.$watch(getIsOpen, function(value) { + scope.isOpen = !!value; + }); + } + + keynavEnabled = angular.isDefined($attrs.keyboardNav); + }; + + this.toggle = function(open) { + scope.isOpen = arguments.length ? !!open : !scope.isOpen; + if (angular.isFunction(setIsOpen)) { + setIsOpen(scope, scope.isOpen); + } + + return scope.isOpen; + }; + + // Allow other directives to watch status + this.isOpen = function() { + return scope.isOpen; + }; + + scope.getToggleElement = function() { + return self.toggleElement; + }; + + scope.getAutoClose = function() { + return $attrs.autoClose || 'always'; //or 'outsideClick' or 'disabled' + }; + + scope.getElement = function() { + return $element; + }; + + scope.isKeynavEnabled = function() { + return keynavEnabled; + }; + + scope.focusDropdownEntry = function(keyCode) { + var elems = self.dropdownMenu ? //If append to body is used. + angular.element(self.dropdownMenu).find('a') : + $element.find('ul').eq(0).find('a'); + + switch (keyCode) { + case 40: { + if (!angular.isNumber(self.selectedOption)) { + self.selectedOption = 0; + } else { + self.selectedOption = self.selectedOption === elems.length - 1 ? + self.selectedOption : + self.selectedOption + 1; + } + break; + } + case 38: { + if (!angular.isNumber(self.selectedOption)) { + self.selectedOption = elems.length - 1; + } else { + self.selectedOption = self.selectedOption === 0 ? + 0 : self.selectedOption - 1; + } + break; + } + } + elems[self.selectedOption].focus(); + }; + + scope.getDropdownElement = function() { + return self.dropdownMenu; + }; + + scope.focusToggleElement = function() { + if (self.toggleElement) { + self.toggleElement[0].focus(); + } + }; + + function removeDropdownMenu() { + $element.append(self.dropdownMenu); + } + + scope.$watch('isOpen', function(isOpen, wasOpen) { + var appendTo = null, + appendToBody = false; + + if (angular.isDefined($attrs.dropdownAppendTo)) { + var appendToEl = $parse($attrs.dropdownAppendTo)(scope); + if (appendToEl) { + appendTo = angular.element(appendToEl); + } + } + + if (angular.isDefined($attrs.dropdownAppendToBody)) { + var appendToBodyValue = $parse($attrs.dropdownAppendToBody)(scope); + if (appendToBodyValue !== false) { + appendToBody = true; + } + } + + if (appendToBody && !appendTo) { + appendTo = body; + } + + if (appendTo && self.dropdownMenu) { + if (isOpen) { + appendTo.append(self.dropdownMenu); + $element.on('$destroy', removeDropdownMenu); + } else { + $element.off('$destroy', removeDropdownMenu); + removeDropdownMenu(); + } + } + + if (appendTo && self.dropdownMenu) { + var pos = $position.positionElements($element, self.dropdownMenu, 'bottom-left', true), + css, + rightalign, + scrollbarPadding, + scrollbarWidth = 0; + + css = { + top: pos.top + 'px', + display: isOpen ? 'block' : 'none' + }; + + rightalign = self.dropdownMenu.hasClass('dropdown-menu-right'); + if (!rightalign) { + css.left = pos.left + 'px'; + css.right = 'auto'; + } else { + css.left = 'auto'; + scrollbarPadding = $position.scrollbarPadding(appendTo); + + if (scrollbarPadding.heightOverflow && scrollbarPadding.scrollbarWidth) { + scrollbarWidth = scrollbarPadding.scrollbarWidth; + } + + css.right = window.innerWidth - scrollbarWidth - + (pos.left + $element.prop('offsetWidth')) + 'px'; + } + + // Need to adjust our positioning to be relative to the appendTo container + // if it's not the body element + if (!appendToBody) { + var appendOffset = $position.offset(appendTo); + + css.top = pos.top - appendOffset.top + 'px'; + + if (!rightalign) { + css.left = pos.left - appendOffset.left + 'px'; + } else { + css.right = window.innerWidth - + (pos.left - appendOffset.left + $element.prop('offsetWidth')) + 'px'; + } + } + + self.dropdownMenu.css(css); + } + + var openContainer = appendTo ? appendTo : $element; + var dropdownOpenClass = appendTo ? appendToOpenClass : openClass; + var hasOpenClass = openContainer.hasClass(dropdownOpenClass); + var isOnlyOpen = uibDropdownService.isOnlyOpen($scope, appendTo); + + if (hasOpenClass === !isOpen) { + var toggleClass; + if (appendTo) { + toggleClass = !isOnlyOpen ? 'addClass' : 'removeClass'; + } else { + toggleClass = isOpen ? 'addClass' : 'removeClass'; + } + $animate[toggleClass](openContainer, dropdownOpenClass).then(function() { + if (angular.isDefined(isOpen) && isOpen !== wasOpen) { + toggleInvoker($scope, { open: !!isOpen }); + } + }); + } + + if (isOpen) { + if (self.dropdownMenuTemplateUrl) { + $templateRequest(self.dropdownMenuTemplateUrl).then(function(tplContent) { + templateScope = scope.$new(); + $compile(tplContent.trim())(templateScope, function(dropdownElement) { + var newEl = dropdownElement; + self.dropdownMenu.replaceWith(newEl); + self.dropdownMenu = newEl; + $document.on('keydown', uibDropdownService.keybindFilter); + }); + }); + } else { + $document.on('keydown', uibDropdownService.keybindFilter); + } + + scope.focusToggleElement(); + uibDropdownService.open(scope, $element, appendTo); + } else { + uibDropdownService.close(scope, $element, appendTo); + if (self.dropdownMenuTemplateUrl) { + if (templateScope) { + templateScope.$destroy(); + } + var newEl = angular.element(''); + self.dropdownMenu.replaceWith(newEl); + self.dropdownMenu = newEl; + } + + self.selectedOption = null; + } + + if (angular.isFunction(setIsOpen)) { + setIsOpen($scope, isOpen); + } + }); +}]) + +.directive('uibDropdown', function() { + return { + controller: 'UibDropdownController', + link: function(scope, element, attrs, dropdownCtrl) { + dropdownCtrl.init(); + } + }; +}) + +.directive('uibDropdownMenu', function() { + return { + restrict: 'A', + require: '?^uibDropdown', + link: function(scope, element, attrs, dropdownCtrl) { + if (!dropdownCtrl || angular.isDefined(attrs.dropdownNested)) { + return; + } + + element.addClass('dropdown-menu'); + + var tplUrl = attrs.templateUrl; + if (tplUrl) { + dropdownCtrl.dropdownMenuTemplateUrl = tplUrl; + } + + if (!dropdownCtrl.dropdownMenu) { + dropdownCtrl.dropdownMenu = element; + } + } + }; +}) + +.directive('uibDropdownToggle', function() { + return { + require: '?^uibDropdown', + link: function(scope, element, attrs, dropdownCtrl) { + if (!dropdownCtrl) { + return; + } + + element.addClass('dropdown-toggle'); + + dropdownCtrl.toggleElement = element; + + var toggleDropdown = function(event) { + event.preventDefault(); + + if (!element.hasClass('disabled') && !attrs.disabled) { + scope.$apply(function() { + dropdownCtrl.toggle(); + }); + } + }; + + element.on('click', toggleDropdown); + + // WAI-ARIA + element.attr({ 'aria-haspopup': true, 'aria-expanded': false }); + scope.$watch(dropdownCtrl.isOpen, function(isOpen) { + element.attr('aria-expanded', !!isOpen); + }); + + scope.$on('$destroy', function() { + element.off('click', toggleDropdown); + }); + } + }; +}); diff --git a/src/dropdown/index-nocss.js b/src/dropdown/index-nocss.js new file mode 100644 index 0000000000..690d7008ff --- /dev/null +++ b/src/dropdown/index-nocss.js @@ -0,0 +1,9 @@ +require('../multiMap'); +require('../position/index-nocss.js'); +require('./dropdown'); + +var MODULE_NAME = 'ui.bootstrap.module.dropdown'; + +angular.module(MODULE_NAME, ['ui.bootstrap.dropdown']); + +module.exports = MODULE_NAME; diff --git a/src/dropdown/index.js b/src/dropdown/index.js new file mode 100644 index 0000000000..93dbeed820 --- /dev/null +++ b/src/dropdown/index.js @@ -0,0 +1,2 @@ +require('../position/position.css'); +module.exports = require('./index-nocss.js'); diff --git a/src/dropdown/test/dropdown.spec.js b/src/dropdown/test/dropdown.spec.js new file mode 100644 index 0000000000..17226b5c5e --- /dev/null +++ b/src/dropdown/test/dropdown.spec.js @@ -0,0 +1,946 @@ +describe('uib-dropdown', function() { + var $animate, $compile, $rootScope, $document, $templateCache, dropdownConfig, element, $browser, $log; + + beforeEach(module('ngAnimateMock')); + beforeEach(module('ui.bootstrap.dropdown')); + + beforeEach(inject(function(_$animate_, _$compile_, _$rootScope_, _$document_, _$templateCache_, uibDropdownConfig, _$browser_, _$log_) { + $animate = _$animate_; + $compile = _$compile_; + $rootScope = _$rootScope_; + $document = _$document_; + $templateCache = _$templateCache_; + dropdownConfig = uibDropdownConfig; + $browser = _$browser_; + $log = _$log_; + })); + + afterEach(function() { + element.remove(); + }); + + var clickDropdownToggle = function(elm) { + elm = elm || element; + elm.find('a[uib-dropdown-toggle]').click(); + }; + + var triggerKeyDown = function (element, keyCode) { + var e = $.Event('keydown'); + spyOn(e, 'stopPropagation'); + e.stopPropagation.and.callThrough(); + e.which = keyCode; + element.trigger(e); + return e; + }; + + describe('basic', function() { + function dropdown() { + return $compile('Hello')($rootScope); + } + + beforeEach(function() { + element = dropdown(); + }); + + it('should toggle on `a` click', function() { + expect(element).not.toHaveClass(dropdownConfig.openClass); + clickDropdownToggle(); + expect(element).toHaveClass(dropdownConfig.openClass); + clickDropdownToggle(); + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + + it('should toggle when an option is clicked', function() { + $document.find('body').append(element); + expect(element).not.toHaveClass(dropdownConfig.openClass); + clickDropdownToggle(); + expect(element).toHaveClass(dropdownConfig.openClass); + + var optionEl = element.find('ul > li').eq(0).find('a').eq(0); + optionEl.click(); + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + + it('should close on document click', function() { + clickDropdownToggle(); + expect(element).toHaveClass(dropdownConfig.openClass); + $document.click(); + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + + it('should close on escape key & focus toggle element', function() { + var dropdownMenu = element.find('[uib-dropdown-menu]'); + $document.find('body').append(element); + clickDropdownToggle(); + var event = triggerKeyDown(dropdownMenu, 27); + expect(element).not.toHaveClass(dropdownConfig.openClass); + expect(element.find('a')).toHaveFocus(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should not close on backspace key', function() { + clickDropdownToggle(); + triggerKeyDown(element, 8); + expect(element).toHaveClass(dropdownConfig.openClass); + }); + + it('should not close on right click', function() { + clickDropdownToggle(); + element.find('ul a').trigger({ + type: 'mousedown', + which: 3 + }); + expect(element).toHaveClass(dropdownConfig.openClass); + }); + + it('should only allow one dropdown to be open at once', function() { + var elm1 = dropdown(); + var elm2 = dropdown(); + expect(elm1).not.toHaveClass(dropdownConfig.openClass); + expect(elm2).not.toHaveClass(dropdownConfig.openClass); + + clickDropdownToggle(elm1); + expect(elm1).toHaveClass(dropdownConfig.openClass); + expect(elm2).not.toHaveClass(dropdownConfig.openClass); + + clickDropdownToggle(elm2); + expect(elm1).not.toHaveClass(dropdownConfig.openClass); + expect(elm2).toHaveClass(dropdownConfig.openClass); + }); + + it('should not toggle if the element has `disabled` class', function() { + var elm = $compile('Hello')($rootScope); + clickDropdownToggle( elm ); + expect(elm).not.toHaveClass(dropdownConfig.openClass); + }); + + it('should not toggle if the element is disabled', function() { + var elm = $compile('Hello')($rootScope); + elm.find('button').click(); + expect(elm).not.toHaveClass(dropdownConfig.openClass); + }); + + it('should not toggle if the element has `ng-disabled` as true', function() { + $rootScope.isdisabled = true; + var elm = $compile('Hello')($rootScope); + $rootScope.$digest(); + elm.find('div').click(); + expect(elm).not.toHaveClass(dropdownConfig.openClass); + + $rootScope.isdisabled = false; + $rootScope.$digest(); + elm.find('div').click(); + expect(elm).toHaveClass(dropdownConfig.openClass); + }); + + it('should unbind events on scope destroy', function() { + var $scope = $rootScope.$new(); + var elm = $compile('Hello')($scope); + $scope.$digest(); + + var buttonEl = elm.find('button'); + buttonEl.click(); + expect(elm).toHaveClass(dropdownConfig.openClass); + buttonEl.click(); + expect(elm).not.toHaveClass(dropdownConfig.openClass); + + $scope.$destroy(); + buttonEl.click(); + expect(elm).not.toHaveClass(dropdownConfig.openClass); + }); + + // issue 270 + it('executes other document click events normally', function() { + var checkboxEl = $compile('')($rootScope); + $rootScope.$digest(); + + expect(element).not.toHaveClass(dropdownConfig.openClass); + expect($rootScope.clicked).toBeFalsy(); + + clickDropdownToggle(); + expect(element).toHaveClass(dropdownConfig.openClass); + expect($rootScope.clicked).toBeFalsy(); + + checkboxEl.click(); + expect($rootScope.clicked).toBeTruthy(); + }); + + // WAI-ARIA + it('should aria markup to the `dropdown-toggle`', function() { + var toggleEl = element.find('a'); + expect(toggleEl.attr('aria-haspopup')).toBe('true'); + expect(toggleEl.attr('aria-expanded')).toBe('false'); + + clickDropdownToggle(); + expect(toggleEl.attr('aria-expanded')).toBe('true'); + clickDropdownToggle(); + expect(toggleEl.attr('aria-expanded')).toBe('false'); + }); + + // pr/issue 3274 + it('should not raise $digest:inprog if dismissed during a digest cycle', function() { + clickDropdownToggle(); + expect(element).toHaveClass(dropdownConfig.openClass); + + $rootScope.$apply(function() { + $document.click(); + }); + + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + }); + + describe('using dropdownMenuTemplate', function() { + function dropdown() { + $templateCache.put('custom.html', 'Item 1'); + + return $compile('')($rootScope); + } + + beforeEach(function() { + element = dropdown(); + }); + + it('should apply custom template for dropdown menu', function() { + element.find('a').click(); + expect(element.find('ul.uib-dropdown-menu').eq(0).find('li').eq(0).text()).toEqual('Item 1'); + }); + + it('should clear ul when dropdown menu is closed', function() { + element.find('a').click(); + expect(element.find('ul.uib-dropdown-menu').eq(0).find('li').eq(0).text()).toEqual('Item 1'); + element.find('a').click(); + expect(element.find('ul.uib-dropdown-menu').eq(0).find('li').length).toEqual(0); + }); + }); + + describe('using dropdown-append-to-body', function() { + describe('with no value', function() { + function dropdown() { + return $compile('Hello On Body')($rootScope); + } + + beforeEach(function() { + element = dropdown(); + $document.find('body').append(element); + }); + + afterEach(function() { + element.remove(); + }); + + it('does not add the menu to the body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + + describe('when toggled open', function() { + var toggle; + beforeEach(function() { + toggle = element.find('[uib-dropdown-toggle]'); + toggle.trigger('click'); + }); + it('adds the menu to the body', function() { + expect($document.find('#dropdown-menu').parent()[0]).toBe($document.find('body')[0]); + }); + + describe('when toggled closed', function() { + beforeEach(function() { + toggle.trigger('click'); + }); + it('removes the menu from body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + }); + + describe('when closed by clicking on menu', function() { + var menu; + beforeEach(function() { + menu = $document.find('#dropdown-menu a'); + menu.focus(); + menu.trigger('click'); + }); + it('focuses the dropdown element on close', function() { + expect(document.activeElement).toBe(toggle[0]); + }); + it('removes the menu from body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + }); + describe('when the dropdown is removed', function() { + beforeEach(function() { + element.remove(); + $rootScope.$digest(); + }); + it('removes the menu from body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + }); + }); + }); + + describe('with a value', function() { + function dropdown() { + return $compile('Hello On Body')($rootScope); + } + describe('that is not false', function() { + beforeEach(function() { + $rootScope.appendToBody = 'sure'; + + element = dropdown(); + $document.find('body').append(element); + }); + + afterEach(function() { + element.remove(); + }); + it('does not add the menu to the body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + + describe('when toggled open', function() { + var toggle; + beforeEach(function() { + toggle = element.find('[uib-dropdown-toggle]'); + toggle.trigger('click'); + }); + it('adds the menu to the body', function() { + expect($document.find('#dropdown-menu').parent()[0]).toBe($document.find('body')[0]); + }); + + describe('when toggled closed', function() { + beforeEach(function() { + toggle.trigger('click'); + }); + it('removes the menu from body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + }); + + describe('when closed by clicking on menu', function() { + var menu; + beforeEach(function() { + menu = $document.find('#dropdown-menu a'); + menu.focus(); + menu.trigger('click'); + }); + it('focuses the dropdown element on close', function() { + expect(document.activeElement).toBe(toggle[0]); + }); + it('removes the menu from body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + }); + describe('when the dropdown is removed', function() { + beforeEach(function() { + element.remove(); + $rootScope.$digest(); + }); + it('removes the menu from body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + }); + }); + }); + + describe('that is false', function() { + beforeEach(function() { + $rootScope.appendToBody = false; + + element = dropdown(); + $document.find('body').append(element); + }); + + afterEach(function() { + element.remove(); + }); + + it('does not add the menu to the body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + + describe('when toggled open', function() { + var toggle; + beforeEach(function() { + toggle = element.find('[uib-dropdown-toggle]'); + toggle.trigger('click'); + }); + it('does not add the menu to the body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + + describe('when toggled closed', function() { + beforeEach(function() { + toggle.trigger('click'); + }); + it('does not remove the menu', function() { + expect($document.find('#dropdown-menu').length).not.toEqual(0); + }); + }); + + describe('when closed by clicking on menu', function() { + var menu; + beforeEach(function() { + menu = $document.find('#dropdown-menu a'); + menu.focus(); + menu.trigger('click'); + }); + it('focuses the dropdown element on close', function() { + expect(document.activeElement).toBe(toggle[0]); + }); + it('does not removes the menu from body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + }); + describe('when the dropdown is removed', function() { + beforeEach(function() { + element.remove(); + $rootScope.$digest(); + }); + it('removes the menu from body', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + }); + }); + }); + }); + }); + + describe('using dropdown-append-to', function() { + var initialPage, container; + + function dropdown() { + return $compile('Hello On Container')($rootScope); + } + + beforeEach(function() { + $document.find('body').append(angular.element('')); + + $rootScope.appendTo = container = $document.find('#dropdown-container'); + + element = dropdown(); + $document.find('body').append(element); + }); + + afterEach(function() { + // Cleanup the extra elements we appended + $document.find('#dropdown-container').remove(); + }); + + it('does not add the menu to the container', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe(container[0]); + }); + it('does not add open class on container', function() { + expect(container).not.toHaveClass('uib-dropdown-open'); + }); + + describe('when toggled open', function() { + var toggle; + beforeEach(function() { + toggle = element.find('[uib-dropdown-toggle]'); + toggle.trigger('click'); + }); + it('adds the menu to the container', function() { + expect($document.find('#dropdown-menu').parent()[0]).toBe(container[0]); + }); + it('adds open class on container', function() { + expect(container).toHaveClass('uib-dropdown-open'); + }); + + describe('when toggled closed', function() { + beforeEach(function() { + toggle.trigger('click'); + }); + it('removes the menu from the container', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + it('removes open class from container', function() { + expect(container).not.toHaveClass('uib-dropdown-open'); + }); + }); + + describe('when closed by clicking on menu', function() { + var menu; + beforeEach(function() { + menu = $document.find('#dropdown-menu a'); + menu.focus(); + menu.trigger('click'); + }); + it('focuses the dropdown element on close', function() { + expect(document.activeElement).toBe(toggle[0]); + }); + it('removes the menu from the container', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + it('removes open class from container', function() { + expect(container).not.toHaveClass('uib-dropdown-open'); + }); + }); + describe('when the dropdown is removed', function() { + beforeEach(function() { + element.remove(); + $rootScope.$digest(); + }); + it('removes the menu from the container', function() { + expect($document.find('#dropdown-menu').parent()[0]).not.toBe($document.find('body')[0]); + }); + }); + }); + }); + + describe('using dropdown-append-to with two dropdowns', function() { + function dropdown() { + return $compile('Hello On ContainerHello On Container')($rootScope); + } + + beforeEach(function() { + $document.find('body').append(angular.element('')); + + $rootScope.appendTo = $document.find('#dropdown-container'); + $rootScope.log = jasmine.createSpy('log'); + + element = dropdown(); + $document.find('body').append(element); + }); + + afterEach(function() { + // Cleanup the extra elements we appended + $document.find('#dropdown-container').remove(); + }); + + it('should keep the class when toggling from one dropdown to another with the same container', function() { + var container = $document.find('#dropdown-container'); + + expect(container).not.toHaveClass('uib-dropdown-open'); + element.find('.dropdown1 [uib-dropdown-toggle]').click(); + expect(container).toHaveClass('uib-dropdown-open'); + element.find('.dropdown2 [uib-dropdown-toggle]').click(); + expect(container).toHaveClass('uib-dropdown-open'); + }); + }); + + describe('using is-open', function() { + describe('with uib-dropdown-toggle', function() { + beforeEach(function() { + $rootScope.isopen = true; + element = $compile('Hello')($rootScope); + $rootScope.$digest(); + }); + + it('should be open initially', function() { + expect(element).toHaveClass(dropdownConfig.openClass); + }); + + it('should change `is-open` binding when toggles', function() { + clickDropdownToggle(); + expect($rootScope.isopen).toBe(false); + }); + + it('should toggle when `is-open` changes', function() { + $rootScope.isopen = false; + $rootScope.$digest(); + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + + it('focus toggle element when opening', function() { + $document.find('body').append(element); + clickDropdownToggle(); + $rootScope.isopen = false; + $rootScope.$digest(); + expect(element.find('a')).not.toHaveFocus(); + $rootScope.isopen = true; + $rootScope.$digest(); + expect(element.find('a')).toHaveFocus(); + }); + }); + + describe('without uib-dropdown-toggle', function() { + beforeEach(function() { + $rootScope.isopen = true; + element = $compile('Hello')($rootScope); + $rootScope.$digest(); + }); + + it('should be open initially', function() { + expect(element).toHaveClass(dropdownConfig.openClass); + }); + + it('should toggle when `is-open` changes', function() { + $rootScope.isopen = false; + $rootScope.$digest(); + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + }); + }); + + describe('using on-toggle', function() { + describe('with is-open to false', function() { + beforeEach(function() { + $rootScope.toggleHandler = jasmine.createSpy('toggleHandler'); + $rootScope.isopen = false; + element = $compile('Hello')($rootScope); + $rootScope.$digest(); + }); + + it('should not have been called initially', function() { + expect($rootScope.toggleHandler).not.toHaveBeenCalled(); + }); + + it('should call it correctly when toggles', function() { + $rootScope.isopen = true; + $rootScope.$digest(); + + $animate.flush(); + $rootScope.$digest(); + expect($rootScope.toggleHandler).toHaveBeenCalledWith(true); + + clickDropdownToggle(); + $animate.flush(); + $rootScope.$digest(); + expect($rootScope.toggleHandler).toHaveBeenCalledWith(false); + }); + }); + + describe('with is-open to true', function() { + beforeEach(function() { + $rootScope.toggleHandler = jasmine.createSpy('toggleHandler'); + $rootScope.isopen = true; + element = $compile('Hello')($rootScope); + $rootScope.$digest(); + }); + + it('should not have been called initially', function() { + expect($rootScope.toggleHandler).not.toHaveBeenCalled(); + }); + + it('should call it correctly when toggles', function() { + $rootScope.isopen = false; + $rootScope.$digest(); + + $animate.flush(); + $rootScope.$digest(); + expect($rootScope.toggleHandler).toHaveBeenCalledWith(false); + + $rootScope.isopen = true; + $rootScope.$digest(); + + $animate.flush(); + $rootScope.$digest(); + expect($rootScope.toggleHandler).toHaveBeenCalledWith(true); + }); + }); + + describe('without is-open', function() { + beforeEach(function() { + $rootScope.toggleHandler = jasmine.createSpy('toggleHandler'); + element = $compile('Hello')($rootScope); + $rootScope.$digest(); + }); + + it('should not have been called initially', function() { + expect($rootScope.toggleHandler).not.toHaveBeenCalled(); + }); + + it('should call it when clicked', function() { + clickDropdownToggle(); + + $animate.flush(); + $rootScope.$digest(); + expect($rootScope.toggleHandler).toHaveBeenCalledWith(true); + + clickDropdownToggle(); + + $animate.flush(); + $rootScope.$digest(); + expect($rootScope.toggleHandler).toHaveBeenCalledWith(false); + }); + }); + }); + + describe('using auto-close', function() { + function dropdown(autoClose) { + return $compile('Hello')($rootScope); + } + + describe('always', function() { + it('should close on document click if no auto-close is specified', function() { + element = dropdown(); + clickDropdownToggle(); + expect(element).toHaveClass(dropdownConfig.openClass); + $document.click(); + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + + it('should close on document click if empty auto-close is specified', function() { + element = dropdown(''); + clickDropdownToggle(); + expect(element).toHaveClass(dropdownConfig.openClass); + $document.click(); + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + }); + + describe('disabled', function() { + it('auto-close="disabled"', function() { + element = dropdown('disabled'); + clickDropdownToggle(); + expect(element).toHaveClass(dropdownConfig.openClass); + $document.click(); + expect(element).toHaveClass(dropdownConfig.openClass); + }); + + it('control with is-open', function() { + $rootScope.isopen = true; + element = $compile('Hello')($rootScope); + $rootScope.$digest(); + + expect(element).toHaveClass(dropdownConfig.openClass); + //should remain open + $document.click(); + expect(element).toHaveClass(dropdownConfig.openClass); + //now should close + $rootScope.isopen = false; + $rootScope.$digest(); + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + + it('should close anyway if toggle is clicked', function() { + element = dropdown('disabled'); + clickDropdownToggle(); + expect(element).toHaveClass(dropdownConfig.openClass); + clickDropdownToggle(); + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + + it('should close anyway if esc is pressed', function() { + element = dropdown('disabled'); + var dropdownMenu = element.find('[uib-dropdown-menu]'); + $document.find('body').append(element); + clickDropdownToggle(); + triggerKeyDown(dropdownMenu, 27); + expect(element).not.toHaveClass(dropdownConfig.openClass); + expect(element.find('a')).toHaveFocus(); + }); + + it('should close anyway if another dropdown is opened', function() { + var elm1 = dropdown('disabled'); + var elm2 = dropdown(); + expect(elm1).not.toHaveClass(dropdownConfig.openClass); + expect(elm2).not.toHaveClass(dropdownConfig.openClass); + clickDropdownToggle(elm1); + expect(elm1).toHaveClass(dropdownConfig.openClass); + expect(elm2).not.toHaveClass(dropdownConfig.openClass); + clickDropdownToggle(elm2); + expect(elm1).not.toHaveClass(dropdownConfig.openClass); + expect(elm2).toHaveClass(dropdownConfig.openClass); + }); + }); + + describe('outsideClick', function() { + it('should close only on a click outside of the dropdown menu', function() { + element = dropdown('outsideClick'); + clickDropdownToggle(); + expect(element).toHaveClass(dropdownConfig.openClass); + element.find('ul li a').click(); + expect(element).toHaveClass(dropdownConfig.openClass); + $document.click(); + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + + it('should work with dropdown-append-to-body', function() { + element = $compile('Hello On Body')($rootScope); + clickDropdownToggle(); + var dropdownMenu = $document.find('#dropdown-menu'); + expect(dropdownMenu.parent()).toHaveClass(dropdownConfig.appendToOpenClass); + dropdownMenu.find('li').eq(0).trigger('click'); + expect(dropdownMenu.parent()).toHaveClass(dropdownConfig.appendToOpenClass); + $document.click(); + expect(dropdownMenu.parent()).not.toHaveClass(dropdownConfig.appendToOpenClass); + }); + }); + }); + + describe('using keyboard-nav', function() { + function dropdown() { + return $compile('HelloHello Again')($rootScope); + } + function getFocusedElement() { + return angular.element(document.activeElement); + } + beforeEach(function() { + element = dropdown(); + }); + + it('should focus first list element when down arrow pressed', function() { + $document.find('body').append(element); + clickDropdownToggle(); + triggerKeyDown(getFocusedElement(), 40); + + expect(element).toHaveClass(dropdownConfig.openClass); + var optionEl = element.find('ul').eq(0).find('a').eq(0); + expect(optionEl).toHaveFocus(); + }); + + it('should not focus first list element when down arrow pressed if closed', function() { + $document.find('body').append(element); + triggerKeyDown(getFocusedElement(), 40); + + expect(element).not.toHaveClass(dropdownConfig.openClass); + var focusEl = element.find('ul').eq(0).find('a').eq(0); + expect(focusEl).not.toHaveFocus(); + }); + + it('should focus second list element when down arrow pressed twice', function() { + $document.find('body').append(element); + clickDropdownToggle(); + triggerKeyDown(getFocusedElement(), 40); + triggerKeyDown(getFocusedElement(), 40); + + expect(element).toHaveClass(dropdownConfig.openClass); + var focusEl = element.find('ul').eq(0).find('a').eq(1); + expect(focusEl).toHaveFocus(); + }); + + it('should not focus first list element when up arrow pressed after dropdown toggled', function() { + $document.find('body').append(element); + clickDropdownToggle(); + expect(element).toHaveClass(dropdownConfig.openClass); + + triggerKeyDown(getFocusedElement(), 38); + var focusEl = element.find('ul').eq(0).find('a').eq(0); + expect(focusEl).not.toHaveFocus(); + }); + + it('should focus last list element when up arrow pressed after dropdown toggled', function() { + $document.find('body').append(element); + clickDropdownToggle(); + triggerKeyDown(getFocusedElement(), 38); + + expect(element).toHaveClass(dropdownConfig.openClass); + var focusEl = element.find('ul').eq(0).find('a').eq(1); + expect(focusEl).toHaveFocus(); + }); + + it('should not change focus when other keys are pressed', function() { + $document.find('body').append(element); + clickDropdownToggle(); + triggerKeyDown(getFocusedElement(), 37); + + expect(element).toHaveClass(dropdownConfig.openClass); + var focusEl = element.find('ul').eq(0).find('a'); + expect(focusEl[0]).not.toHaveFocus(); + expect(focusEl[1]).not.toHaveFocus(); + }); + + it('should focus first list element when down arrow pressed 2x and up pressed 1x', function() { + $document.find('body').append(element); + clickDropdownToggle(); + triggerKeyDown(getFocusedElement(), 40); + triggerKeyDown(getFocusedElement(), 40); + + triggerKeyDown(getFocusedElement(), 38); + + expect(element).toHaveClass(dropdownConfig.openClass); + var focusEl = element.find('ul').eq(0).find('a').eq(0); + expect(focusEl).toHaveFocus(); + }); + + it('should stay focused on final list element if down pressed at list end', function() { + $document.find('body').append(element); + clickDropdownToggle(); + triggerKeyDown(getFocusedElement(), 40); + triggerKeyDown(getFocusedElement(), 40); + + expect(element).toHaveClass(dropdownConfig.openClass); + var focusEl = element.find('ul').eq(0).find('a').eq(1); + expect(focusEl).toHaveFocus(); + + triggerKeyDown(element, 40); + expect(focusEl).toHaveFocus(); + }); + + it('should close if esc is pressed while focused', function() { + element = dropdown('disabled'); + $document.find('body').append(element); + clickDropdownToggle(); + + triggerKeyDown(getFocusedElement(), 40); + + expect(element).toHaveClass(dropdownConfig.openClass); + var focusEl = element.find('ul').eq(0).find('a').eq(0); + expect(focusEl).toHaveFocus(); + + triggerKeyDown(getFocusedElement(), 27); + expect(element).not.toHaveClass(dropdownConfig.openClass); + }); + + describe('with dropdown-append-to-body', function() { + function dropdown() { + return $compile('fooHello On BodyHello Again')($rootScope); + } + + beforeEach(function() { + element = dropdown(); + }); + + it('should focus first list element when down arrow pressed', function() { + $document.find('body').append(element); + clickDropdownToggle(); + + var dropdownMenu = $document.find('#dropdown-menu'); + + triggerKeyDown(getFocusedElement(), 40); + + expect(dropdownMenu.parent()).toHaveClass(dropdownConfig.appendToOpenClass); + var focusEl = $document.find('ul').eq(0).find('a'); + expect(focusEl).toHaveFocus(); + }); + + it('should focus second list element when down arrow pressed twice', function() { + $document.find('body').append(element); + clickDropdownToggle(); + var dropdownMenu = $document.find('#dropdown-menu'); + triggerKeyDown(getFocusedElement(), 40); + triggerKeyDown(getFocusedElement(), 40); + triggerKeyDown(getFocusedElement(), 40); + + expect(dropdownMenu.parent()).toHaveClass(dropdownConfig.appendToOpenClass); + var elem1 = $document.find('ul'); + var elem2 = elem1.find('a'); + var focusEl = $document.find('ul').eq(0).find('a').eq(1); + expect(focusEl).toHaveFocus(); + }); + }); + }); + + // issue #5942 + describe('using dropdown-append-to-body with dropdown-menu-right class', function() { + function dropdown() { + return $compile('Toggle menuHello On Body')($rootScope); + } + + beforeEach(function() { + element = dropdown(); + $document.find('body').append(element); + + var menu = $document.find('#dropdown-menu'); + menu.css('position', 'absolute'); + }); + + afterEach(function() { + element.remove(); + }); + + it('should align the menu correctly when the body has no vertical scrollbar', function() { + var toggle = element.find('[uib-dropdown-toggle]'); + var menu = $document.find('#dropdown-menu'); + toggle.trigger('click'); + + // Get the offsets of the rightmost position of both the toggle and the menu (offset from the left of the window) + var toggleRight = Math.round(toggle.offset().left + toggle.outerWidth()); + var menuRight = Math.round(menu.offset().left + menu.outerWidth()); + expect(menuRight).toBe(toggleRight); + }); + }); +}); diff --git a/src/dropdownToggle/docs/demo.html b/src/dropdownToggle/docs/demo.html deleted file mode 100644 index 5c12fe9708..0000000000 --- a/src/dropdownToggle/docs/demo.html +++ /dev/null @@ -1,10 +0,0 @@ - - - Click me for a dropdown, yo! - - - - {{choice}} - - - diff --git a/src/dropdownToggle/docs/demo.js b/src/dropdownToggle/docs/demo.js deleted file mode 100644 index d7333d9648..0000000000 --- a/src/dropdownToggle/docs/demo.js +++ /dev/null @@ -1,7 +0,0 @@ -function DropdownCtrl($scope) { - $scope.items = [ - "The first choice!", - "And another choice for you.", - "but wait! A third!" - ]; -} diff --git a/src/dropdownToggle/docs/readme.md b/src/dropdownToggle/docs/readme.md deleted file mode 100644 index 2e42c46be6..0000000000 --- a/src/dropdownToggle/docs/readme.md +++ /dev/null @@ -1,2 +0,0 @@ - -DropdownToggle is a simple directive which will toggle a dropdown link on click. Simply put it on the `` tag of the toggler-element, and it will find the nearest dropdown menu and toggle it when the `` is clicked. diff --git a/src/dropdownToggle/dropdownToggle.js b/src/dropdownToggle/dropdownToggle.js deleted file mode 100644 index e04ccf7b90..0000000000 --- a/src/dropdownToggle/dropdownToggle.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * dropdownToggle - Provides dropdown menu functionality in place of bootstrap js - * @restrict class or attribute - * @example: - - My Dropdown Menu - - - {{choice.text}} - - - - */ - -angular.module('ui.bootstrap.dropdownToggle', []).directive('dropdownToggle', ['$document', '$location', function ($document, $location) { - var openElement = null, - closeMenu = angular.noop; - return { - restrict: 'CA', - link: function(scope, element, attrs) { - scope.$watch('$location.path', function() { closeMenu(); }); - element.parent().bind('click', function() { closeMenu(); }); - element.bind('click', function (event) { - - var elementWasOpen = (element === openElement); - - event.preventDefault(); - event.stopPropagation(); - - if (!!openElement) { - closeMenu(); - } - - if (!elementWasOpen) { - element.parent().addClass('open'); - openElement = element; - closeMenu = function (event) { - if (event) { - event.preventDefault(); - event.stopPropagation(); - } - $document.unbind('click', closeMenu); - element.parent().removeClass('open'); - closeMenu = angular.noop; - openElement = null; - }; - $document.bind('click', closeMenu); - } - }); - } - }; -}]); \ No newline at end of file diff --git a/src/dropdownToggle/test/dropdownToggleSpec.js b/src/dropdownToggle/test/dropdownToggleSpec.js deleted file mode 100644 index ae5b154e99..0000000000 --- a/src/dropdownToggle/test/dropdownToggleSpec.js +++ /dev/null @@ -1,67 +0,0 @@ -describe('dropdownToggle', function() { - var $compile, $rootScope, $document, $location; - - beforeEach(module('ui.bootstrap.dropdownToggle')); - - beforeEach(inject(function(_$compile_, _$rootScope_, _$document_, _$location_) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $document = _$document_; - $location = _$location_; - - })); - - function dropdown() { - return $compile('Hello')($rootScope); - } - - it('should toggle on `a` click', function() { - var elm = dropdown(); - expect(elm.hasClass('open')).toBe(false); - elm.find('a').click(); - expect(elm.hasClass('open')).toBe(true); - elm.find('a').click(); - expect(elm.hasClass('open')).toBe(false); - }); - - it('should toggle on `ul` click', function() { - var elm = dropdown(); - expect(elm.hasClass('open')).toBe(false); - elm.find('ul').click(); - expect(elm.hasClass('open')).toBe(true); - elm.find('ul').click(); - expect(elm.hasClass('open')).toBe(false); - }); - - it('should close on elm click', function() { - var elm = dropdown(); - elm.find('a').click(); - elm.click(); - expect(elm.hasClass('open')).toBe(false); - }); - - it('should close on document click', function() { - var elm = dropdown(); - elm.find('a').click(); - $document.click(); - expect(elm.hasClass('open')).toBe(false); - }); - - it('should close on $location change', function() { - var elm = dropdown(); - elm.find('a').click(); - $location.path('/foo'); - $rootScope.$apply(); - expect(elm.hasClass('open')).toBe(false); - }); - - it('should only allow one dropdown to be open at once', function() { - var elm1 = dropdown(); - var elm2 = dropdown(); - elm1.find('a').click(); - elm2.find('a').click(); - expect(elm1.hasClass('open')).toBe(false); - expect(elm2.hasClass('open')).toBe(true); - }); -}); - diff --git a/src/isClass/index.js b/src/isClass/index.js new file mode 100644 index 0000000000..3d7d33860b --- /dev/null +++ b/src/isClass/index.js @@ -0,0 +1,7 @@ +require('./isClass'); + +var MODULE_NAME = 'ui.bootstrap.module.isClass'; + +angular.module(MODULE_NAME, ['ui.bootstrap.isClass']); + +module.exports = MODULE_NAME; diff --git a/src/isClass/isClass.js b/src/isClass/isClass.js new file mode 100644 index 0000000000..b0e4912b5b --- /dev/null +++ b/src/isClass/isClass.js @@ -0,0 +1,97 @@ +// Avoiding use of ng-class as it creates a lot of watchers when a class is to be applied to +// at most one element. +angular.module('ui.bootstrap.isClass', []) +.directive('uibIsClass', [ + '$animate', +function ($animate) { + // 11111111 22222222 + var ON_REGEXP = /^\s*([\s\S]+?)\s+on\s+([\s\S]+?)\s*$/; + // 11111111 22222222 + var IS_REGEXP = /^\s*([\s\S]+?)\s+for\s+([\s\S]+?)\s*$/; + + var dataPerTracked = {}; + + return { + restrict: 'A', + compile: function(tElement, tAttrs) { + var linkedScopes = []; + var instances = []; + var expToData = {}; + var lastActivated = null; + var onExpMatches = tAttrs.uibIsClass.match(ON_REGEXP); + var onExp = onExpMatches[2]; + var expsStr = onExpMatches[1]; + var exps = expsStr.split(','); + + return linkFn; + + function linkFn(scope, element, attrs) { + linkedScopes.push(scope); + instances.push({ + scope: scope, + element: element + }); + + exps.forEach(function(exp, k) { + addForExp(exp, scope); + }); + + scope.$on('$destroy', removeScope); + } + + function addForExp(exp, scope) { + var matches = exp.match(IS_REGEXP); + var clazz = scope.$eval(matches[1]); + var compareWithExp = matches[2]; + var data = expToData[exp]; + if (!data) { + var watchFn = function(compareWithVal) { + var newActivated = null; + instances.some(function(instance) { + var thisVal = instance.scope.$eval(onExp); + if (thisVal === compareWithVal) { + newActivated = instance; + return true; + } + }); + if (data.lastActivated !== newActivated) { + if (data.lastActivated) { + $animate.removeClass(data.lastActivated.element, clazz); + } + if (newActivated) { + $animate.addClass(newActivated.element, clazz); + } + data.lastActivated = newActivated; + } + }; + expToData[exp] = data = { + lastActivated: null, + scope: scope, + watchFn: watchFn, + compareWithExp: compareWithExp, + watcher: scope.$watch(compareWithExp, watchFn) + }; + } + data.watchFn(scope.$eval(compareWithExp)); + } + + function removeScope(e) { + var removedScope = e.targetScope; + var index = linkedScopes.indexOf(removedScope); + linkedScopes.splice(index, 1); + instances.splice(index, 1); + if (linkedScopes.length) { + var newWatchScope = linkedScopes[0]; + angular.forEach(expToData, function(data) { + if (data.scope === removedScope) { + data.watcher = newWatchScope.$watch(data.compareWithExp, data.watchFn); + data.scope = newWatchScope; + } + }); + } else { + expToData = {}; + } + } + } + }; +}]); \ No newline at end of file diff --git a/src/isClass/test/isClass.spec.js b/src/isClass/test/isClass.spec.js new file mode 100644 index 0000000000..87a7a6a035 --- /dev/null +++ b/src/isClass/test/isClass.spec.js @@ -0,0 +1,80 @@ +describe('uibIsClass', function() { + var $rootScope; + + beforeEach(module('ui.bootstrap.isClass')); + beforeEach(inject(function($compile, _$rootScope_) { + $rootScope = _$rootScope_; + $rootScope.activeClass = 'active'; + $rootScope.items = [1, 2, 3]; + element = $compile('{{ item }}')($rootScope); + $rootScope.$digest(); + })); + + it('initializes classes correctly', function() { + expect(element.find('.active').length).toEqual(0); + }); + + it('sets classes correctly', function() { + $rootScope.activeItem = 2; + $rootScope.$digest(); + expect(element.find('.active').text()).toEqual('2'); + + $rootScope.items.splice(1, 1); + $rootScope.$digest(); + expect(element.find('.active').length).toEqual(0); + }); + + it('handles removal of items correctly', function() { + $rootScope.activeItem = 2; + $rootScope.$digest(); + expect(element.find('.active').text()).toEqual('2'); + + $rootScope.items.splice(1, 1); + $rootScope.$digest(); + expect(element.find('.active').length).toEqual(0); + + $rootScope.activeItem = 1; + $rootScope.$digest(); + expect(element.find('.active').text()).toEqual('1'); + }); + + it('handles moving of items', function() { + $rootScope.activeItem = 2; + $rootScope.items = [2, 1, 3]; + $rootScope.$digest(); + expect(element.find('.active').text()).toEqual('2'); + expect(element.find('.active').length).toEqual(1); + expect(element.find('.active').index()).toEqual(0); + + $rootScope.items = [4, 3, 2]; + $rootScope.$digest(); + expect(element.find('.active').text()).toEqual('2'); + expect(element.find('.active').length).toEqual(1); + expect(element.find('.active').index()).toEqual(2); + }); + + it('handles emptying and re-adding the items', function() { + $rootScope.activeItem = 2; + $rootScope.items = []; + $rootScope.$digest(); + expect(element.find('.active').length).toEqual(0); + + $rootScope.items = [4, 3, 2]; + $rootScope.$digest(); + expect(element.find('.active').text()).toEqual('2'); + expect(element.find('.active').index()).toEqual(2); + }); + + it('handles undefined items', function() { + $rootScope.activeItem = undefined; + $rootScope.items = []; + $rootScope.$digest(); + expect(element.find('.active').length).toEqual(0); + + $rootScope.items = [4, 3, undefined]; + $rootScope.$digest(); + expect(element.find('.active').length).toEqual(1); + expect(element.find('.active').text()).toEqual(''); + }); +}); \ No newline at end of file diff --git a/src/modal/docs/demo.html b/src/modal/docs/demo.html index 524bf8bf95..616b1a0c03 100644 --- a/src/modal/docs/demo.html +++ b/src/modal/docs/demo.html @@ -1,16 +1,44 @@ - - Open me! - + + + + + Open me! + Large modal + Small modal + + Modal appended to a custom parent + + Toggle Animation ({{ $ctrl.animationsEnabled }}) + Open a component modal! + + Open multiple modals at once + + Selection from a modal: {{ $ctrl.selected }} + - \ No newline at end of file + diff --git a/src/modal/docs/demo.js b/src/modal/docs/demo.js index ff307fea8c..53b335dad1 100644 --- a/src/modal/docs/demo.js +++ b/src/modal/docs/demo.js @@ -1,19 +1,126 @@ -var ModalDemoCtrl = function ($scope) { +angular.module('ui.bootstrap.demo').controller('ModalDemoCtrl', function ($uibModal, $log, $document) { + var $ctrl = this; + $ctrl.items = ['item1', 'item2', 'item3']; - $scope.open = function () { - $scope.shouldBeOpen = true; + $ctrl.animationsEnabled = true; + + $ctrl.open = function (size, parentSelector) { + var parentElem = parentSelector ? + angular.element($document[0].querySelector('.modal-demo ' + parentSelector)) : undefined; + var modalInstance = $uibModal.open({ + animation: $ctrl.animationsEnabled, + ariaLabelledBy: 'modal-title', + ariaDescribedBy: 'modal-body', + templateUrl: 'myModalContent.html', + controller: 'ModalInstanceCtrl', + controllerAs: '$ctrl', + size: size, + appendTo: parentElem, + resolve: { + items: function () { + return $ctrl.items; + } + } + }); + + modalInstance.result.then(function (selectedItem) { + $ctrl.selected = selectedItem; + }, function () { + $log.info('Modal dismissed at: ' + new Date()); + }); + }; + + $ctrl.openComponentModal = function () { + var modalInstance = $uibModal.open({ + animation: $ctrl.animationsEnabled, + component: 'modalComponent', + resolve: { + items: function () { + return $ctrl.items; + } + } + }); + + modalInstance.result.then(function (selectedItem) { + $ctrl.selected = selectedItem; + }, function () { + $log.info('modal-component dismissed at: ' + new Date()); + }); + }; + + $ctrl.openMultipleModals = function () { + $uibModal.open({ + animation: $ctrl.animationsEnabled, + ariaLabelledBy: 'modal-title-bottom', + ariaDescribedBy: 'modal-body-bottom', + templateUrl: 'stackedModal.html', + size: 'sm', + controller: function($scope) { + $scope.name = 'bottom'; + } + }); + + $uibModal.open({ + animation: $ctrl.animationsEnabled, + ariaLabelledBy: 'modal-title-top', + ariaDescribedBy: 'modal-body-top', + templateUrl: 'stackedModal.html', + size: 'sm', + controller: function($scope) { + $scope.name = 'top'; + } + }); }; - $scope.close = function () { - $scope.closeMsg = 'I was closed at: ' + new Date(); - $scope.shouldBeOpen = false; + $ctrl.toggleAnimation = function () { + $ctrl.animationsEnabled = !$ctrl.animationsEnabled; }; +}); - $scope.items = ['item1', 'item2']; +// Please note that $uibModalInstance represents a modal window (instance) dependency. +// It is not the same as the $uibModal service used above. - $scope.opts = { - backdropFade: true, - dialogFade:true +angular.module('ui.bootstrap.demo').controller('ModalInstanceCtrl', function ($uibModalInstance, items) { + var $ctrl = this; + $ctrl.items = items; + $ctrl.selected = { + item: $ctrl.items[0] }; -}; \ No newline at end of file + $ctrl.ok = function () { + $uibModalInstance.close($ctrl.selected.item); + }; + + $ctrl.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; +}); + +// Please note that the close and dismiss bindings are from $uibModalInstance. + +angular.module('ui.bootstrap.demo').component('modalComponent', { + templateUrl: 'myModalContent.html', + bindings: { + resolve: '<', + close: '&', + dismiss: '&' + }, + controller: function () { + var $ctrl = this; + + $ctrl.$onInit = function () { + $ctrl.items = $ctrl.resolve.items; + $ctrl.selected = { + item: $ctrl.items[0] + }; + }; + + $ctrl.ok = function () { + $ctrl.close({$value: $ctrl.selected.item}); + }; + + $ctrl.cancel = function () { + $ctrl.dismiss({$value: 'cancel'}); + }; + } +}); diff --git a/src/modal/docs/readme.md b/src/modal/docs/readme.md index f72d844c57..66b6f36803 100644 --- a/src/modal/docs/readme.md +++ b/src/modal/docs/readme.md @@ -1,5 +1,161 @@ -`modal` is a directive that reuses `$dialog` service to provide simple creation of modals that are already in your DOM without the hassle of creating partial views and controllers. +`$uibModal` is a service to create modal windows. +Creating modals is straightforward: create a template and controller, and reference them when using `$uibModal`. -The directive shares `$dialog` global options. +The `$uibModal` service has only one method: `open(options)`. -For more information, see the [dialog readme](https://github.com/angular-ui/bootstrap/blob/master/src/dialog/README.md) on github. +### $uibModal's open function + +#### options parameter + +* `animation` + _(Type: `boolean`, Default: `true`)_ - + Set to false to disable animations on new modal/backdrop. Does not toggle animations for modals/backdrops that are already displayed. + +* `appendTo` + _(Type: `angular.element`, Default: `body`: Example: `$document.find('aside').eq(0)`)_ - + Appends the modal to a specific element. + +* `ariaDescribedBy` + _(Type: `string`, `my-modal-description`)_ - + Sets the [`aria-describedby`](https://www.w3.org/TR/wai-aria/states_and_properties#aria-describedby) property on the modal. The value should be an id (without the leading `#`) pointing to the element that describes your modal. Typically, this will be the text on your modal, but does not include something the user would interact with, like buttons or a form. Omitting this option will not impact sighted users but will weaken your accessibility support. + +* `ariaLabelledBy` + _(Type: `string`, `my-modal-title`)_ - + Sets the [`aria-labelledby`](https://www.w3.org/TR/wai-aria/states_and_properties#aria-labelledby) property on the modal. The value should be an id (without the leading `#`) pointing to the element that labels your modal. Typically, this will be a header element. Omitting this option will not impact sighted users but will weaken your accessibility support. + +* `backdrop` + _(Type: `boolean|string`, Default: `true`)_ - + Controls presence of a backdrop. Allowed values: `true` (default), `false` (no backdrop), `'static'` (disables modal closing by click on the backdrop). + +* `backdropClass` + _(Type: `string`)_ - + Additional CSS class(es) to be added to a modal backdrop template. + +* `bindToController` + _(Type: `boolean`, Default: `false`)_ - + When used with `controllerAs` & set to `true`, it will bind the $scope properties onto the controller. + +* `component` + _(Type: `string`, Example: `myComponent`)_ - + A string reference to the component to be rendered that is registered with Angular's compiler. If using a directive, the directive must have `restrict: 'E'` and a template or templateUrl set. + + It supports these bindings: + + * `close` - A method that can be used to close a modal, passing a result. The result must be passed in this format: `{$value: myResult}` + + * `dismiss` - A method that can be used to dismiss a modal, passing a result. The result must be passed in this format: `{$value: myRejectedResult}` + + * `modalInstance` - The modal instance. This is the same `$uibModalInstance` injectable found when using `controller`. + + * `resolve` - An object of the modal resolve values. See [UI Router resolves](#ui-router-resolves) for details. + +* `controller` + _(Type: `function|string|array`, Example: `MyModalController`)_ - + A controller for the modal instance, either a controller name as a string, or an inline controller function, optionally wrapped in array notation for dependency injection. Allows the controller-as syntax. Has a special `$uibModalInstance` injectable to access the modal instance. + +* `controllerAs` + _(Type: `string`, Example: `ctrl`)_ - + An alternative to the controller-as syntax. Requires the `controller` option to be provided as well. + +* `keyboard` - + _(Type: `boolean`, Default: `true`)_ - + Indicates whether the dialog should be closable by hitting the ESC key. + +* `openedClass` + _(Type: `string`, Default: `modal-open`)_ - + Class added to the `body` element when the modal is opened. + +* `resolve` + _(Type: `Object`)_ - + Members that will be resolved and passed to the controller as locals; it is equivalent of the `resolve` property in the router. + +* `scope` + _(Type: `$scope`)_ - + The parent scope instance to be used for the modal's content. Defaults to `$rootScope`. + +* `size` + _(Type: `string`, Example: `lg`)_ - + Optional suffix of modal window class. The value used is appended to the `modal-` class, i.e. a value of `sm` gives `modal-sm`. + +* `template` + _(Type: `string`)_ - + Inline template representing the modal's content. + +* `templateUrl` + _(Type: `string`)_ - + A path to a template representing modal's content. You need either a `template` or `templateUrl`. + +* `windowClass` + _(Type: `string`)_ - + Additional CSS class(es) to be added to a modal window template. + +* `windowTemplateUrl` + _(Type: `string`, Default: `uib/template/modal/window.html`)_ - + A path to a template overriding modal's window template. + +* `windowTopClass` + _(Type: `string`)_ - + CSS class(es) to be added to the top modal window. + +Global defaults may be set for `$uibModal` via `$uibModalProvider.options`. + +#### return + +The `open` method returns a modal instance, an object with the following properties: + +* `close(result)` + _(Type: `function`)_ - + Can be used to close a modal, passing a result. + +* `dismiss(reason)` + _(Type: `function`)_ - + Can be used to dismiss a modal, passing a reason. + +* `result` + _(Type: `promise`)_ - + Is resolved when a modal is closed and rejected when a modal is dismissed. + +* `opened` + _(Type: `promise`)_ - + Is resolved when a modal gets opened after downloading content's template and resolving all variables. + +* `closed` + _(Type: `promise`)_ - + Is resolved when a modal is closed and the animation completes. + +* `rendered` + _(Type: `promise`)_ - + Is resolved when a modal is rendered. + +--- + +The scope associated with modal's content is augmented with: + +* `$close(result)` + _(Type: `function`)_ - + A method that can be used to close a modal, passing a result. + +* `$dismiss(reason)` + _(Type: `function`)_ - + A method that can be used to dismiss a modal, passing a reason. + +Those methods make it easy to close a modal window without a need to create a dedicated controller. + +Also, when using `bindToController`, you can define an `$onInit` method in the controller that will fire upon initialization. + +--- + +Events fired: + +* `$uibUnscheduledDestruction` - + This event is fired if the $scope is destroyed via unexpected mechanism, such as it being passed in the modal options and a $route/$state transition occurs. The modal will also be dismissed. + +* `modal.closing` - + This event is broadcast to the modal scope before the modal closes. If the listener calls preventDefault() on the event, then the modal will remain open. + Also, the `$close` and `$dismiss` methods returns true if the event was executed. This event also includes a parameter for the result/reason and a boolean that indicates whether the modal is being closed (true) or dismissed. + +##### UI Router resolves + +If one wants to have the modal resolve using [UI Router's](https://github.com/angular-ui/ui-router) pre-1.0 resolve mechanism, one can call `$uibResolve.setResolver('$resolve')` in the configuration phase of the application. One can also provide a custom resolver as well, as long as the signature conforms to UI Router's [$resolve](http://angular-ui.github.io/ui-router/site/#/api/ui.router.util.$resolve). + +When the modal is opened with a controller, a `$resolve` object is exposed on the template with the resolved values from the resolve object. If using the component option, see details on how to access this object in component section of the modal documentation. diff --git a/src/modal/index-nocss.js b/src/modal/index-nocss.js new file mode 100644 index 0000000000..d3f8a94b7d --- /dev/null +++ b/src/modal/index-nocss.js @@ -0,0 +1,11 @@ +require('../multiMap'); +require('../position/index-nocss.js'); +require('../stackedMap'); +require('../../template/modal/window.html.js'); +require('./modal'); + +var MODULE_NAME = 'ui.bootstrap.module.modal'; + +angular.module(MODULE_NAME, ['ui.bootstrap.modal', 'uib/template/modal/window.html']); + +module.exports = MODULE_NAME; diff --git a/src/modal/index.js b/src/modal/index.js new file mode 100644 index 0000000000..93dbeed820 --- /dev/null +++ b/src/modal/index.js @@ -0,0 +1,2 @@ +require('../position/position.css'); +module.exports = require('./index-nocss.js'); diff --git a/src/modal/modal.js b/src/modal/modal.js index 7f8a828a6d..679ff188e8 100644 --- a/src/modal/modal.js +++ b/src/modal/modal.js @@ -1,47 +1,830 @@ -angular.module('ui.bootstrap.modal', ['ui.bootstrap.dialog']) -.directive('modal', ['$parse', '$dialog', function($parse, $dialog) { - return { - restrict: 'EA', - terminal: true, - link: function(scope, elm, attrs) { - var opts = angular.extend({}, scope.$eval(attrs.uiOptions || attrs.bsOptions || attrs.options)); - var shownExpr = attrs.modal || attrs.show; - var setClosed; - - // Create a dialog with the template as the contents of the directive - // Add the current scope as the resolve in order to make the directive scope as a dialog controller scope - opts = angular.extend(opts, { - template: elm.html(), - resolve: { $scope: function() { return scope; } } - }); - var dialog = $dialog.dialog(opts); +angular.module('ui.bootstrap.modal', ['ui.bootstrap.multiMap', 'ui.bootstrap.stackedMap', 'ui.bootstrap.position']) +/** + * Pluggable resolve mechanism for the modal resolve resolution + * Supports UI Router's $resolve service + */ + .provider('$uibResolve', function() { + var resolve = this; + this.resolver = null; + + this.setResolver = function(resolver) { + this.resolver = resolver; + }; + + this.$get = ['$injector', '$q', function($injector, $q) { + var resolver = resolve.resolver ? $injector.get(resolve.resolver) : null; + return { + resolve: function(invocables, locals, parent, self) { + if (resolver) { + return resolver.resolve(invocables, locals, parent, self); + } + + var promises = []; + + angular.forEach(invocables, function(value) { + if (angular.isFunction(value) || angular.isArray(value)) { + promises.push($q.resolve($injector.invoke(value))); + } else if (angular.isString(value)) { + promises.push($q.resolve($injector.get(value))); + } else { + promises.push($q.resolve(value)); + } + }); + + return $q.all(promises).then(function(resolves) { + var resolveObj = {}; + var resolveIter = 0; + angular.forEach(invocables, function(value, key) { + resolveObj[key] = resolves[resolveIter++]; + }); + + return resolveObj; + }); + } + }; + }]; + }) - elm.remove(); +/** + * A helper directive for the $modal service. It creates a backdrop element. + */ + .directive('uibModalBackdrop', ['$animate', '$injector', '$uibModalStack', + function($animate, $injector, $modalStack) { + return { + restrict: 'A', + compile: function(tElement, tAttrs) { + tElement.addClass(tAttrs.backdropClass); + return linkFn; + } + }; - if (attrs.close) { - setClosed = function() { - $parse(attrs.close)(scope); + function linkFn(scope, element, attrs) { + if (attrs.modalInClass) { + $animate.addClass(element, attrs.modalInClass); + + scope.$on($modalStack.NOW_CLOSING_EVENT, function(e, setIsAsync) { + var done = setIsAsync(); + if (scope.modalOptions.animation) { + $animate.removeClass(element, attrs.modalInClass).then(done); + } else { + done(); + } + }); + } + } + }]) + + .directive('uibModalWindow', ['$uibModalStack', '$q', '$animateCss', '$document', + function($modalStack, $q, $animateCss, $document) { + return { + scope: { + index: '@' + }, + restrict: 'A', + transclude: true, + templateUrl: function(tElement, tAttrs) { + return tAttrs.templateUrl || 'uib/template/modal/window.html'; + }, + link: function(scope, element, attrs) { + element.addClass(attrs.windowTopClass || ''); + scope.size = attrs.size; + + scope.close = function(evt) { + var modal = $modalStack.getTop(); + if (modal && modal.value.backdrop && + modal.value.backdrop !== 'static' && + evt.target === evt.currentTarget) { + evt.preventDefault(); + evt.stopPropagation(); + $modalStack.dismiss(modal.key, 'backdrop click'); + } }; - } else { - setClosed = function() { - if (angular.isFunction($parse(shownExpr).assign)) { - $parse(shownExpr).assign(scope, false); + + // moved from template to fix issue #2280 + element.on('click', scope.close); + + // This property is only added to the scope for the purpose of detecting when this directive is rendered. + // We can detect that by using this property in the template associated with this directive and then use + // {@link Attribute#$observe} on it. For more details please see {@link TableColumnResize}. + scope.$isRendered = true; + + // Deferred object that will be resolved when this modal is rendered. + var modalRenderDeferObj = $q.defer(); + // Resolve render promise post-digest + scope.$$postDigest(function() { + modalRenderDeferObj.resolve(); + }); + + modalRenderDeferObj.promise.then(function() { + var animationPromise = null; + + if (attrs.modalInClass) { + animationPromise = $animateCss(element, { + addClass: attrs.modalInClass + }).start(); + + scope.$on($modalStack.NOW_CLOSING_EVENT, function(e, setIsAsync) { + var done = setIsAsync(); + $animateCss(element, { + removeClass: attrs.modalInClass + }).start().then(done); + }); } + + + $q.when(animationPromise).then(function() { + // Notify {@link $modalStack} that modal is rendered. + var modal = $modalStack.getTop(); + if (modal) { + $modalStack.modalRendered(modal.key); + } + + /** + * If something within the freshly-opened modal already has focus (perhaps via a + * directive that causes focus) then there's no need to try to focus anything. + */ + if (!($document[0].activeElement && element[0].contains($document[0].activeElement))) { + var inputWithAutofocus = element[0].querySelector('[autofocus]'); + /** + * Auto-focusing of a freshly-opened modal element causes any child elements + * with the autofocus attribute to lose focus. This is an issue on touch + * based devices which will show and then hide the onscreen keyboard. + * Attempts to refocus the autofocus element via JavaScript will not reopen + * the onscreen keyboard. Fixed by updated the focusing logic to only autofocus + * the modal element if the modal does not contain an autofocus element. + */ + if (inputWithAutofocus) { + inputWithAutofocus.focus(); + } else { + element[0].focus(); + } + } + }); + }); + } + }; + }]) + + .directive('uibModalAnimationClass', function() { + return { + compile: function(tElement, tAttrs) { + if (tAttrs.modalAnimation) { + tElement.addClass(tAttrs.uibModalAnimationClass); + } + } + }; + }) + + .directive('uibModalTransclude', ['$animate', function($animate) { + return { + link: function(scope, element, attrs, controller, transclude) { + transclude(scope.$parent, function(clone) { + element.empty(); + $animate.enter(clone, element); + }); + } + }; + }]) + + .factory('$uibModalStack', ['$animate', '$animateCss', '$document', + '$compile', '$rootScope', '$q', '$$multiMap', '$$stackedMap', '$uibPosition', + function($animate, $animateCss, $document, $compile, $rootScope, $q, $$multiMap, $$stackedMap, $uibPosition) { + var OPENED_MODAL_CLASS = 'modal-open'; + + var backdropDomEl, backdropScope; + var openedWindows = $$stackedMap.createNew(); + var openedClasses = $$multiMap.createNew(); + var $modalStack = { + NOW_CLOSING_EVENT: 'modal.stack.now-closing' + }; + var topModalIndex = 0; + var previousTopOpenedModal = null; + var ARIA_HIDDEN_ATTRIBUTE_NAME = 'data-bootstrap-modal-aria-hidden-count'; + + //Modal focus behavior + var tabbableSelector = 'a[href], area[href], input:not([disabled]):not([tabindex=\'-1\']), ' + + 'button:not([disabled]):not([tabindex=\'-1\']),select:not([disabled]):not([tabindex=\'-1\']), textarea:not([disabled]):not([tabindex=\'-1\']), ' + + 'iframe, object, embed, *[tabindex]:not([tabindex=\'-1\']), *[contenteditable=true]'; + var scrollbarPadding; + var SNAKE_CASE_REGEXP = /[A-Z]/g; + + // TODO: extract into common dependency with tooltip + function snake_case(name) { + var separator = '-'; + return name.replace(SNAKE_CASE_REGEXP, function(letter, pos) { + return (pos ? separator : '') + letter.toLowerCase(); + }); + } + + function isVisible(element) { + return !!(element.offsetWidth || + element.offsetHeight || + element.getClientRects().length); + } + + function backdropIndex() { + var topBackdropIndex = -1; + var opened = openedWindows.keys(); + for (var i = 0; i < opened.length; i++) { + if (openedWindows.get(opened[i]).value.backdrop) { + topBackdropIndex = i; + } + } + + // If any backdrop exist, ensure that it's index is always + // right below the top modal + if (topBackdropIndex > -1 && topBackdropIndex < topModalIndex) { + topBackdropIndex = topModalIndex; + } + return topBackdropIndex; + } + + $rootScope.$watch(backdropIndex, function(newBackdropIndex) { + if (backdropScope) { + backdropScope.index = newBackdropIndex; + } + }); + + function removeModalWindow(modalInstance, elementToReceiveFocus) { + var modalWindow = openedWindows.get(modalInstance).value; + var appendToElement = modalWindow.appendTo; + + //clean up the stack + openedWindows.remove(modalInstance); + previousTopOpenedModal = openedWindows.top(); + if (previousTopOpenedModal) { + topModalIndex = parseInt(previousTopOpenedModal.value.modalDomEl.attr('index'), 10); + } + + removeAfterAnimate(modalWindow.modalDomEl, modalWindow.modalScope, function() { + var modalBodyClass = modalWindow.openedClass || OPENED_MODAL_CLASS; + openedClasses.remove(modalBodyClass, modalInstance); + var areAnyOpen = openedClasses.hasKey(modalBodyClass); + appendToElement.toggleClass(modalBodyClass, areAnyOpen); + if (!areAnyOpen && scrollbarPadding && scrollbarPadding.heightOverflow && scrollbarPadding.scrollbarWidth) { + if (scrollbarPadding.originalRight) { + appendToElement.css({paddingRight: scrollbarPadding.originalRight + 'px'}); + } else { + appendToElement.css({paddingRight: ''}); + } + scrollbarPadding = null; + } + toggleTopWindowClass(true); + }, modalWindow.closedDeferred); + checkRemoveBackdrop(); + + //move focus to specified element if available, or else to body + if (elementToReceiveFocus && elementToReceiveFocus.focus) { + elementToReceiveFocus.focus(); + } else if (appendToElement.focus) { + appendToElement.focus(); + } + } + + // Add or remove "windowTopClass" from the top window in the stack + function toggleTopWindowClass(toggleSwitch) { + var modalWindow; + + if (openedWindows.length() > 0) { + modalWindow = openedWindows.top().value; + modalWindow.modalDomEl.toggleClass(modalWindow.windowTopClass || '', toggleSwitch); + } + } + + function checkRemoveBackdrop() { + //remove backdrop if no longer needed + if (backdropDomEl && backdropIndex() === -1) { + var backdropScopeRef = backdropScope; + removeAfterAnimate(backdropDomEl, backdropScope, function() { + backdropScopeRef = null; + }); + backdropDomEl = undefined; + backdropScope = undefined; + } + } + + function removeAfterAnimate(domEl, scope, done, closedDeferred) { + var asyncDeferred; + var asyncPromise = null; + var setIsAsync = function() { + if (!asyncDeferred) { + asyncDeferred = $q.defer(); + asyncPromise = asyncDeferred.promise; + } + + return function asyncDone() { + asyncDeferred.resolve(); + }; }; + scope.$broadcast($modalStack.NOW_CLOSING_EVENT, setIsAsync); + + // Note that it's intentional that asyncPromise might be null. + // That's when setIsAsync has not been called during the + // NOW_CLOSING_EVENT broadcast. + return $q.when(asyncPromise).then(afterAnimating); + + function afterAnimating() { + if (afterAnimating.done) { + return; + } + afterAnimating.done = true; + + $animate.leave(domEl).then(function() { + if (done) { + done(); + } + + domEl.remove(); + if (closedDeferred) { + closedDeferred.resolve(); + } + }); + + scope.$destroy(); + } + } + + $document.on('keydown', keydownListener); + + $rootScope.$on('$destroy', function() { + $document.off('keydown', keydownListener); + }); + + function keydownListener(evt) { + if (evt.isDefaultPrevented()) { + return evt; + } + + var modal = openedWindows.top(); + if (modal) { + switch (evt.which) { + case 27: { + if (modal.value.keyboard) { + evt.preventDefault(); + $rootScope.$apply(function() { + $modalStack.dismiss(modal.key, 'escape key press'); + }); + } + break; + } + case 9: { + var list = $modalStack.loadFocusElementList(modal); + var focusChanged = false; + if (evt.shiftKey) { + if ($modalStack.isFocusInFirstItem(evt, list) || $modalStack.isModalFocused(evt, modal)) { + focusChanged = $modalStack.focusLastFocusableElement(list); + } + } else { + if ($modalStack.isFocusInLastItem(evt, list)) { + focusChanged = $modalStack.focusFirstFocusableElement(list); + } + } + + if (focusChanged) { + evt.preventDefault(); + evt.stopPropagation(); + } + + break; + } + } + } } - scope.$watch(shownExpr, function(isShown, oldShown) { - if (isShown) { - dialog.open().then(function(){ - setClosed(); + $modalStack.open = function(modalInstance, modal) { + var modalOpener = $document[0].activeElement, + modalBodyClass = modal.openedClass || OPENED_MODAL_CLASS; + + toggleTopWindowClass(false); + + // Store the current top first, to determine what index we ought to use + // for the current top modal + previousTopOpenedModal = openedWindows.top(); + + openedWindows.add(modalInstance, { + deferred: modal.deferred, + renderDeferred: modal.renderDeferred, + closedDeferred: modal.closedDeferred, + modalScope: modal.scope, + backdrop: modal.backdrop, + keyboard: modal.keyboard, + openedClass: modal.openedClass, + windowTopClass: modal.windowTopClass, + animation: modal.animation, + appendTo: modal.appendTo + }); + + openedClasses.put(modalBodyClass, modalInstance); + + var appendToElement = modal.appendTo, + currBackdropIndex = backdropIndex(); + + if (currBackdropIndex >= 0 && !backdropDomEl) { + backdropScope = $rootScope.$new(true); + backdropScope.modalOptions = modal; + backdropScope.index = currBackdropIndex; + backdropDomEl = angular.element(''); + backdropDomEl.attr({ + 'class': 'modal-backdrop', + 'ng-style': '{\'z-index\': 1040 + (index && 1 || 0) + index*10}', + 'uib-modal-animation-class': 'fade', + 'modal-in-class': 'in' + }); + if (modal.backdropClass) { + backdropDomEl.addClass(modal.backdropClass); + } + + if (modal.animation) { + backdropDomEl.attr('modal-animation', 'true'); + } + $compile(backdropDomEl)(backdropScope); + $animate.enter(backdropDomEl, appendToElement); + if ($uibPosition.isScrollable(appendToElement)) { + scrollbarPadding = $uibPosition.scrollbarPadding(appendToElement); + if (scrollbarPadding.heightOverflow && scrollbarPadding.scrollbarWidth) { + appendToElement.css({paddingRight: scrollbarPadding.right + 'px'}); + } + } + } + + var content; + if (modal.component) { + content = document.createElement(snake_case(modal.component.name)); + content = angular.element(content); + content.attr({ + resolve: '$resolve', + 'modal-instance': '$uibModalInstance', + close: '$close($value)', + dismiss: '$dismiss($value)' }); } else { - //Make sure it is not opened - if (dialog.isOpen()){ - dialog.close(); + content = modal.content; + } + + // Set the top modal index based on the index of the previous top modal + topModalIndex = previousTopOpenedModal ? parseInt(previousTopOpenedModal.value.modalDomEl.attr('index'), 10) + 1 : 0; + var angularDomEl = angular.element(''); + angularDomEl.attr({ + 'class': 'modal', + 'template-url': modal.windowTemplateUrl, + 'window-top-class': modal.windowTopClass, + 'role': 'dialog', + 'aria-labelledby': modal.ariaLabelledBy, + 'aria-describedby': modal.ariaDescribedBy, + 'size': modal.size, + 'index': topModalIndex, + 'animate': 'animate', + 'ng-style': '{\'z-index\': 1050 + $$topModalIndex*10, display: \'block\'}', + 'tabindex': -1, + 'uib-modal-animation-class': 'fade', + 'modal-in-class': 'in' + }).append(content); + if (modal.windowClass) { + angularDomEl.addClass(modal.windowClass); + } + + if (modal.animation) { + angularDomEl.attr('modal-animation', 'true'); + } + + appendToElement.addClass(modalBodyClass); + if (modal.scope) { + // we need to explicitly add the modal index to the modal scope + // because it is needed by ngStyle to compute the zIndex property. + modal.scope.$$topModalIndex = topModalIndex; + } + $animate.enter($compile(angularDomEl)(modal.scope), appendToElement); + + openedWindows.top().value.modalDomEl = angularDomEl; + openedWindows.top().value.modalOpener = modalOpener; + + applyAriaHidden(angularDomEl); + + function applyAriaHidden(el) { + if (!el || el[0].tagName === 'BODY') { + return; + } + + getSiblings(el).forEach(function(sibling) { + var elemIsAlreadyHidden = sibling.getAttribute('aria-hidden') === 'true', + ariaHiddenCount = parseInt(sibling.getAttribute(ARIA_HIDDEN_ATTRIBUTE_NAME), 10); + + if (!ariaHiddenCount) { + ariaHiddenCount = elemIsAlreadyHidden ? 1 : 0; + } + + sibling.setAttribute(ARIA_HIDDEN_ATTRIBUTE_NAME, ariaHiddenCount + 1); + sibling.setAttribute('aria-hidden', 'true'); + }); + + return applyAriaHidden(el.parent()); + + function getSiblings(el) { + var children = el.parent() ? el.parent().children() : []; + + return Array.prototype.filter.call(children, function(child) { + return child !== el[0]; + }); } } - }); - } - }; -}]); \ No newline at end of file + }; + + function broadcastClosing(modalWindow, resultOrReason, closing) { + return !modalWindow.value.modalScope.$broadcast('modal.closing', resultOrReason, closing).defaultPrevented; + } + + function unhideBackgroundElements() { + Array.prototype.forEach.call( + document.querySelectorAll('[' + ARIA_HIDDEN_ATTRIBUTE_NAME + ']'), + function(hiddenEl) { + var ariaHiddenCount = parseInt(hiddenEl.getAttribute(ARIA_HIDDEN_ATTRIBUTE_NAME), 10), + newHiddenCount = ariaHiddenCount - 1; + hiddenEl.setAttribute(ARIA_HIDDEN_ATTRIBUTE_NAME, newHiddenCount); + + if (!newHiddenCount) { + hiddenEl.removeAttribute(ARIA_HIDDEN_ATTRIBUTE_NAME); + hiddenEl.removeAttribute('aria-hidden'); + } + } + ); + } + + $modalStack.close = function(modalInstance, result) { + var modalWindow = openedWindows.get(modalInstance); + unhideBackgroundElements(); + if (modalWindow && broadcastClosing(modalWindow, result, true)) { + modalWindow.value.modalScope.$$uibDestructionScheduled = true; + modalWindow.value.deferred.resolve(result); + removeModalWindow(modalInstance, modalWindow.value.modalOpener); + return true; + } + + return !modalWindow; + }; + + $modalStack.dismiss = function(modalInstance, reason) { + var modalWindow = openedWindows.get(modalInstance); + unhideBackgroundElements(); + if (modalWindow && broadcastClosing(modalWindow, reason, false)) { + modalWindow.value.modalScope.$$uibDestructionScheduled = true; + modalWindow.value.deferred.reject(reason); + removeModalWindow(modalInstance, modalWindow.value.modalOpener); + return true; + } + return !modalWindow; + }; + + $modalStack.dismissAll = function(reason) { + var topModal = this.getTop(); + while (topModal && this.dismiss(topModal.key, reason)) { + topModal = this.getTop(); + } + }; + + $modalStack.getTop = function() { + return openedWindows.top(); + }; + + $modalStack.modalRendered = function(modalInstance) { + var modalWindow = openedWindows.get(modalInstance); + if (modalWindow) { + modalWindow.value.renderDeferred.resolve(); + } + }; + + $modalStack.focusFirstFocusableElement = function(list) { + if (list.length > 0) { + list[0].focus(); + return true; + } + return false; + }; + + $modalStack.focusLastFocusableElement = function(list) { + if (list.length > 0) { + list[list.length - 1].focus(); + return true; + } + return false; + }; + + $modalStack.isModalFocused = function(evt, modalWindow) { + if (evt && modalWindow) { + var modalDomEl = modalWindow.value.modalDomEl; + if (modalDomEl && modalDomEl.length) { + return (evt.target || evt.srcElement) === modalDomEl[0]; + } + } + return false; + }; + + $modalStack.isFocusInFirstItem = function(evt, list) { + if (list.length > 0) { + return (evt.target || evt.srcElement) === list[0]; + } + return false; + }; + + $modalStack.isFocusInLastItem = function(evt, list) { + if (list.length > 0) { + return (evt.target || evt.srcElement) === list[list.length - 1]; + } + return false; + }; + + $modalStack.loadFocusElementList = function(modalWindow) { + if (modalWindow) { + var modalDomE1 = modalWindow.value.modalDomEl; + if (modalDomE1 && modalDomE1.length) { + var elements = modalDomE1[0].querySelectorAll(tabbableSelector); + return elements ? + Array.prototype.filter.call(elements, function(element) { + return isVisible(element); + }) : elements; + } + } + }; + + return $modalStack; + }]) + + .provider('$uibModal', function() { + var $modalProvider = { + options: { + animation: true, + backdrop: true, //can also be false or 'static' + keyboard: true + }, + $get: ['$rootScope', '$q', '$document', '$templateRequest', '$controller', '$uibResolve', '$uibModalStack', + function ($rootScope, $q, $document, $templateRequest, $controller, $uibResolve, $modalStack) { + var $modal = {}; + + function getTemplatePromise(options) { + return options.template ? $q.when(options.template) : + $templateRequest(angular.isFunction(options.templateUrl) ? + options.templateUrl() : options.templateUrl); + } + + var promiseChain = null; + $modal.getPromiseChain = function() { + return promiseChain; + }; + + $modal.open = function(modalOptions) { + var modalResultDeferred = $q.defer(); + var modalOpenedDeferred = $q.defer(); + var modalClosedDeferred = $q.defer(); + var modalRenderDeferred = $q.defer(); + + //prepare an instance of a modal to be injected into controllers and returned to a caller + var modalInstance = { + result: modalResultDeferred.promise, + opened: modalOpenedDeferred.promise, + closed: modalClosedDeferred.promise, + rendered: modalRenderDeferred.promise, + close: function (result) { + return $modalStack.close(modalInstance, result); + }, + dismiss: function (reason) { + return $modalStack.dismiss(modalInstance, reason); + } + }; + + //merge and clean up options + modalOptions = angular.extend({}, $modalProvider.options, modalOptions); + modalOptions.resolve = modalOptions.resolve || {}; + modalOptions.appendTo = modalOptions.appendTo || $document.find('body').eq(0); + + if (!modalOptions.appendTo.length) { + throw new Error('appendTo element not found. Make sure that the element passed is in DOM.'); + } + + //verify options + if (!modalOptions.component && !modalOptions.template && !modalOptions.templateUrl) { + throw new Error('One of component or template or templateUrl options is required.'); + } + + var templateAndResolvePromise; + if (modalOptions.component) { + templateAndResolvePromise = $q.when($uibResolve.resolve(modalOptions.resolve, {}, null, null)); + } else { + templateAndResolvePromise = + $q.all([getTemplatePromise(modalOptions), $uibResolve.resolve(modalOptions.resolve, {}, null, null)]); + } + + function resolveWithTemplate() { + return templateAndResolvePromise; + } + + // Wait for the resolution of the existing promise chain. + // Then switch to our own combined promise dependency (regardless of how the previous modal fared). + // Then add to $modalStack and resolve opened. + // Finally clean up the chain variable if no subsequent modal has overwritten it. + var samePromise; + samePromise = promiseChain = $q.all([promiseChain]) + .then(resolveWithTemplate, resolveWithTemplate) + .then(function resolveSuccess(tplAndVars) { + var providedScope = modalOptions.scope || $rootScope; + + var modalScope = providedScope.$new(); + modalScope.$close = modalInstance.close; + modalScope.$dismiss = modalInstance.dismiss; + + modalScope.$on('$destroy', function() { + if (!modalScope.$$uibDestructionScheduled) { + modalScope.$dismiss('$uibUnscheduledDestruction'); + } + }); + + var modal = { + scope: modalScope, + deferred: modalResultDeferred, + renderDeferred: modalRenderDeferred, + closedDeferred: modalClosedDeferred, + animation: modalOptions.animation, + backdrop: modalOptions.backdrop, + keyboard: modalOptions.keyboard, + backdropClass: modalOptions.backdropClass, + windowTopClass: modalOptions.windowTopClass, + windowClass: modalOptions.windowClass, + windowTemplateUrl: modalOptions.windowTemplateUrl, + ariaLabelledBy: modalOptions.ariaLabelledBy, + ariaDescribedBy: modalOptions.ariaDescribedBy, + size: modalOptions.size, + openedClass: modalOptions.openedClass, + appendTo: modalOptions.appendTo + }; + + var component = {}; + var ctrlInstance, ctrlInstantiate, ctrlLocals = {}; + + if (modalOptions.component) { + constructLocals(component, false, true, false); + component.name = modalOptions.component; + modal.component = component; + } else if (modalOptions.controller) { + constructLocals(ctrlLocals, true, false, true); + + // the third param will make the controller instantiate later,private api + // @see https://github.com/angular/angular.js/blob/master/src/ng/controller.js#L126 + ctrlInstantiate = $controller(modalOptions.controller, ctrlLocals, true, modalOptions.controllerAs); + if (modalOptions.controllerAs && modalOptions.bindToController) { + ctrlInstance = ctrlInstantiate.instance; + ctrlInstance.$close = modalScope.$close; + ctrlInstance.$dismiss = modalScope.$dismiss; + angular.extend(ctrlInstance, { + $resolve: ctrlLocals.$scope.$resolve + }, providedScope); + } + + ctrlInstance = ctrlInstantiate(); + + if (angular.isFunction(ctrlInstance.$onInit)) { + ctrlInstance.$onInit(); + } + } + + if (!modalOptions.component) { + modal.content = tplAndVars[0]; + } + + $modalStack.open(modalInstance, modal); + modalOpenedDeferred.resolve(true); + + function constructLocals(obj, template, instanceOnScope, injectable) { + obj.$scope = modalScope; + obj.$scope.$resolve = {}; + if (instanceOnScope) { + obj.$scope.$uibModalInstance = modalInstance; + } else { + obj.$uibModalInstance = modalInstance; + } + + var resolves = template ? tplAndVars[1] : tplAndVars; + angular.forEach(resolves, function(value, key) { + if (injectable) { + obj[key] = value; + } + + obj.$scope.$resolve[key] = value; + }); + } + }, function resolveError(reason) { + modalOpenedDeferred.reject(reason); + modalResultDeferred.reject(reason); + })['finally'](function() { + if (promiseChain === samePromise) { + promiseChain = null; + } + }); + + return modalInstance; + }; + + return $modal; + } + ] + }; + + return $modalProvider; + }); diff --git a/src/modal/test/modal.spec.js b/src/modal/test/modal.spec.js index e212b0e14a..4bf85243da 100644 --- a/src/modal/test/modal.spec.js +++ b/src/modal/test/modal.spec.js @@ -1,171 +1,2119 @@ -describe('Give ui.boostrap.modal', function() { - - var $document, $compile, $scope, $rootScope, provider; - - beforeEach(module('ui.bootstrap.modal')); - - beforeEach(function(){ - module(function($dialogProvider){ - provider = $dialogProvider; - }); - inject(function(_$document_, _$compile_, _$rootScope_){ - $document = _$document_; - $compile = _$compile_; - $scope = _$rootScope_.$new(); - $rootScope = _$rootScope_; - }); - }); - - var elm; - - var templateGenerator = function(expr, scopeExpressionContent, closeExpr) { - var additionalExpression = scopeExpressionContent ? scopeExpressionContent : ''; - var closingExpr = closeExpr ? ' close="' + closeExpr + '" ': ''; - return '' + - additionalExpression + 'Hello!'; - }; - - it('should have just one backdrop', function() { - var numberOfSimultaneousModals = 5; - var elems = []; - for (var i = 0; i< 5; i++) { - elems[i] = $compile(templateGenerator('modalShown' + i))($scope); - $scope.$apply('modalShown' + i + ' = true'); - } - expect($document.find('body > div.modal-backdrop').length).toBe(1); - expect($document.find('body > div.modal').length).toBe(numberOfSimultaneousModals); - - for (i = 0; i< 5; i++) { - $scope.$apply('modalShown' + i + ' = false'); - } - }); - - it('should work with expression instead of a variable', function() { - $scope.foo = true; - $scope.shown = function() { return $scope.foo; }; - elm = $compile(templateGenerator('shown()'))($scope); - $scope.$apply(); - expect($document.find('body > div.modal').length).toBe(1); - $scope.$apply('foo = false'); - expect($document.find('body > div.modal').length).toBe(0); - }); - - it('should work with a close expression and escape close', function() { - $scope.bar = true; - $scope.show = function() { return $scope.bar; }; - elm = $compile(templateGenerator('show()', ' ', 'bar=false'))($scope); - $scope.$apply(); - expect($document.find('body > div.modal').length).toBe(1); - var e = $.Event('keydown'); - e.which = 27; - $document.find('body').trigger(e); - expect($document.find('body > div.modal').length).toBe(0); - expect($scope.bar).not.toBeTruthy(); - }); - - it('should work with a close expression and backdrop close', function() { - $scope.baz = 1; - $scope.hello = function() { return $scope.baz===1; }; - elm = $compile(templateGenerator('hello()', ' ', 'baz=0'))($scope); - $scope.$apply(); - expect($document.find('body > div.modal').length).toBe(1); - $document.find('body > div.modal-backdrop').click(); - expect($document.find('body > div.modal').length).toBe(0); - expect($scope.baz).toBe(0); - }); - - it('should not close on escape if option is false', function() { - $scope.modalOpts = {keyboard:false}; - elm = $compile(templateGenerator('modalShown'))($scope); - $scope.modalShown = true; - $scope.$apply(); - var e = $.Event('keydown'); - e.which = 27; - expect($document.find('body > div.modal').length).toBe(1); - $document.find('body').trigger(e); - expect($document.find('body > div.modal').length).toBe(1); - $scope.$apply('modalShown = false'); - }); - - it('should not close on backdrop click if option is false', function() { - $scope.modalOpts = {backdropClick:false}; - elm = $compile(templateGenerator('modalShown'))($scope); - $scope.modalShown = true; - $scope.$apply(); - expect($document.find('body > div.modal').length).toBe(1); - $document.find('body > div.modal-backdrop').click(); - expect($document.find('body > div.modal').length).toBe(1); - $scope.$apply('modalShown = false'); - }); - - it('should use global $dialog options', function() { - elm = $compile(templateGenerator('modalShown'))($scope); - expect($document.find('.test-open-modal').length).toBe(0); - $scope.$apply('modalShown = true'); - expect($document.find('body > div.modal').length).toBe(1); - $scope.$apply('modalShown = false'); - }); - - describe('dialog generated should have directives scope', function() { - - afterEach(function() { - $scope.$apply('modalShown = false'); - }); - - it('should call scope methods', function() { - var clickSpy = jasmine.createSpy('localScopeFunction'); - $scope.myFunc = clickSpy; - elm = $compile(templateGenerator('modalShown', 'Click'))($scope); - $scope.$apply('modalShown = true'); - $document.find('body > div.modal button').click(); - expect(clickSpy).toHaveBeenCalled(); - }); - - it('should resolve scope vars', function() { - $scope.buttonName = 'my button'; - elm = $compile(templateGenerator('modalShown', '{{buttonName}}'))($scope); - $scope.$apply('modalShown = true'); - expect($document.find('body > div.modal button').text()).toBe('my button'); - }); - - }); - - describe('toogle modal dialog on model change', function() { - - beforeEach(function(){ - elm = $compile(templateGenerator('modalShown'))($scope); - $scope.$apply('modalShown = true'); - }); - - afterEach(function() { - $scope.$apply('modalShown = false'); - }); - - it('the backdrop should be displayed if specified (true by default)', function(){ - expect($document.find('body > div.modal-backdrop').css('display')).toBe('block'); - }); - - it('the modal should be displayed', function(){ - expect($document.find('body > div.modal').css('display')).toBe('block'); - }); - - it('the modal should not be displayed', function(){ - $scope.$apply('modalShown = false'); - expect($document.find('body > div.modal').length).toBe(0); - }); - - it('should update the model if the backdrop is clicked', function() { - $document.find('body > div.modal-backdrop').click(); - $scope.$digest(); - expect($scope.modalShown).not.toBeTruthy(); - }); - - it('should update the model if the esc is pressed', function() { - var e = $.Event('keydown'); - e.which = 27; - $document.find('body').trigger(e); - $scope.$digest(); - expect($scope.modalShown).not.toBeTruthy(); - }); - }); -}); \ No newline at end of file +describe('$uibResolve', function() { + beforeEach(module('ui.bootstrap.modal')); + + it('should resolve invocables and return promise with object of resolutions', function() { + module(function($provide) { + $provide.factory('bar', function() { + return 'bar'; + }); + }); + + inject(function($q, $rootScope, $uibResolve) { + $uibResolve.resolve({ + foo: 'bar', + bar: $q.resolve('baz'), + baz: function() { + return 'boo'; + } + }).then(function(resolves) { + expect(resolves).toEqual({ + foo: 'bar', + bar: 'baz', + baz: 'boo' + }); + }); + + $rootScope.$digest(); + }); + }); + + describe('with custom resolver', function() { + beforeEach(module(function($provide, $uibResolveProvider) { + $provide.factory('$resolve', function() { + return { + resolve: jasmine.createSpy() + }; + }); + + $uibResolveProvider.setResolver('$resolve'); + })); + + it('should call $resolve.resolve', inject(function($uibResolve, $resolve) { + $uibResolve.resolve({foo: 'bar'}, {}, null, null); + + expect($resolve.resolve).toHaveBeenCalledWith({foo: 'bar'}, {}, null, null); + })); + }); +}); + +describe('uibModalTransclude', function() { + var uibModalTranscludeDDO, + $animate; + + beforeEach(module('ui.bootstrap.modal')); + beforeEach(module(function($provide) { + $animate = jasmine.createSpyObj('$animate', ['enter']); + $provide.value('$animate', $animate); + })); + + beforeEach(inject(function(uibModalTranscludeDirective) { + uibModalTranscludeDDO = uibModalTranscludeDirective[0]; + })); + + describe('when initialised', function() { + var scope, + element, + transcludeSpy, + transcludeFn; + + beforeEach(function() { + scope = { + $parent: 'parentScope' + }; + + element = jasmine.createSpyObj('containerElement', ['empty']); + transcludeSpy = jasmine.createSpy('transcludeSpy').and.callFake(function(scope, fn) { + transcludeFn = fn; + }); + + uibModalTranscludeDDO.link(scope, element, {}, {}, transcludeSpy); + }); + + it('should call the transclusion function', function() { + expect(transcludeSpy).toHaveBeenCalledWith(scope.$parent, jasmine.any(Function)); + }); + + describe('transclusion callback', function() { + var transcludedContent; + + beforeEach(function() { + transcludedContent = 'my transcluded content'; + transcludeFn(transcludedContent); + }); + + it('should empty the element', function() { + expect(element.empty).toHaveBeenCalledWith(); + }); + + it('should append the transcluded content', function() { + expect($animate.enter).toHaveBeenCalledWith(transcludedContent, element); + }); + }); + }); +}); + +describe('$uibModal', function() { + var $animate, $controllerProvider, $rootScope, $document, $compile, $templateCache, $timeout, $q; + var $uibModal, $uibModalStack, $uibModalProvider; + + beforeEach(module('ngAnimateMock')); + beforeEach(module('ui.bootstrap.modal')); + beforeEach(module('uib/template/modal/window.html')); + beforeEach(module(function(_$controllerProvider_, _$uibModalProvider_, $compileProvider) { + $controllerProvider = _$controllerProvider_; + $uibModalProvider = _$uibModalProvider_; + $compileProvider.directive('parentDirective', function() { + return { + controller: function() { + this.text = 'foo'; + } + }; + }).directive('childDirective', function() { + return { + require: '^parentDirective', + link: function(scope, elem, attrs, ctrl) { + scope.text = ctrl.text; + } + }; + }).directive('focusMe', function() { + return { + link: function(scope, elem, attrs) { + elem.focus(); + } + }; + }).component('fooBar', { + bindings: { + resolve: '<', + modalInstance: '<', + close: '&', + dismiss: '&' + }, + controller: angular.noop, + controllerAs: 'foobar', + template: 'Foo Bar' + }); + })); + + beforeEach(inject(function(_$animate_, _$rootScope_, _$document_, _$compile_, _$templateCache_, _$timeout_, _$q_, _$uibModal_, _$uibModalStack_) { + $animate = _$animate_; + $rootScope = _$rootScope_; + $document = _$document_; + $compile = _$compile_; + $templateCache = _$templateCache_; + $timeout = _$timeout_; + $q = _$q_; + $uibModal = _$uibModal_; + $uibModalStack = _$uibModalStack_; + })); + + beforeEach(function() { + jasmine.addMatchers({ + toBeResolvedWith: function(util, customEqualityTesters) { + return { + compare: function(promise, expected) { + var called = false; + promise.then(function(result) { + expect(result).toEqual(expected); + + if (result === expected) { + result.message = 'Expected "' + angular.mock.dump(result) + '" not to be resolved with "' + expected + '".'; + } else { + result.message = 'Expected "' + angular.mock.dump(result) + '" to be resolved with "' + expected + '".'; + } + }, function(result) { + fail('Expected "' + angular.mock.dump(result) + '" to be resolved with "' + expected + '".'); + })['finally'](function() { + called = true; + }); + + $rootScope.$digest(); + + if (!called) { + fail('Expected "' + angular.mock.dump(result) + '" to be resolved with "' + expected + '".'); + } + + return {pass: true}; + } + }; + }, + toBeRejectedWith: function(util, customEqualityTesters) { + return { + compare: function(promise, expected) { + var result = {}; + var called = false; + + promise.then(function(result) { + fail('Expected "' + angular.mock.dump(result) + '" to be rejected with "' + expected + '".'); + }, function(result) { + expect(result).toEqual(expected); + + if (result === expected) { + result.message = 'Expected "' + angular.mock.dump(result) + '" not to be rejected with "' + expected + '".'; + } else { + result.message = 'Expected "' + angular.mock.dump(result) + '" to be rejected with "' + expected + '".'; + } + })['finally'](function() { + called = true; + }); + + $rootScope.$digest(); + + if (!called) { + fail('Expected "' + angular.mock.dump(result) + '" to be rejected with "' + expected + '".'); + } + + return {pass: true}; + } + }; + }, + toHaveModalOpenWithContent: function(util, customEqualityTesters) { + return { + compare: function(actual, content, selector) { + var contentToCompare, modalDomEls = actual.find('body > div.modal > div.modal-dialog > div.modal-content'); + + contentToCompare = selector ? modalDomEls.find(selector) : modalDomEls; + + var result = { + pass: modalDomEls.css('display') === 'block' && contentToCompare.html() === content + }; + + if (result.pass) { + result.message = '"Expected "' + angular.mock.dump(modalDomEls) + '" not to be open with "' + content + '".'; + } else { + result.message = '"Expected "' + angular.mock.dump(modalDomEls) + '" to be open with "' + content + '".'; + } + + return result; + } + }; + }, + toHaveModalsOpen: function(util, customEqualityTesters) { + return { + compare: function(actual, expected) { + var modalDomEls = actual.find('body > div.modal'); + + var result = { + pass: util.equals(modalDomEls.length, expected, customEqualityTesters) + }; + + if (result.pass) { + result.message = 'Expected "' + angular.mock.dump(modalDomEls) + '" not to have "' + expected + '" modals opened.'; + } else { + result.message = 'Expected "' + angular.mock.dump(modalDomEls) + '" to have "' + expected + '" modals opened.'; + } + + return result; + } + }; + }, + toHaveBackdrop: function(util, customEqualityTesters) { + return { + compare: function(actual, expected) { + var backdropDomEls = actual.find('body > div.modal-backdrop'); + + var result = { + pass: util.equals(backdropDomEls.length, 1, customEqualityTesters) + }; + + if (result.pass) { + result.message = 'Expected "' + angular.mock.dump(backdropDomEls) + '" not to be a backdrop element".'; + } else { + result.message = 'Expected "' + angular.mock.dump(backdropDomEls) + '" to be a backdrop element".'; + } + + return result; + } + }; + } + }); + }); + + afterEach(function () { + var body = $document.find('body'); + body.find('div.modal').remove(); + body.find('div.modal-backdrop').remove(); + body.removeClass('modal-open'); + $document.off('keydown'); + }); + + function triggerKeyDown(element, keyCode, shiftKey) { + var e = $.Event('keydown'); + e.srcElement = element[0]; + e.which = keyCode; + e.shiftKey = shiftKey; + element.trigger(e); + } + + function open(modalOptions, noFlush, noDigest) { + var modal = $uibModal.open(modalOptions); + modal.opened['catch'](angular.noop); + modal.result['catch'](angular.noop); + + if (!noDigest) { + $rootScope.$digest(); + if (!noFlush) { + $animate.flush(); + } + } + + return modal; + } + + function close(modal, result, noFlush) { + var closed = modal.close(result); + $rootScope.$digest(); + if (!noFlush) { + $animate.flush(); + $rootScope.$digest(); + $animate.flush(); + $rootScope.$digest(); + } + return closed; + } + + function dismiss(modal, reason, noFlush) { + var closed = modal.dismiss(reason); + $rootScope.$digest(); + if (!noFlush) { + $animate.flush(); + $rootScope.$digest(); + $animate.flush(); + $rootScope.$digest(); + } + return closed; + } + + describe('basic scenarios with default options', function() { + it('should open and dismiss a modal with a minimal set of options', function() { + var modal = open({template: 'Content'}); + + expect($document).toHaveModalsOpen(1); + expect($document).toHaveModalOpenWithContent('Content', 'div'); + expect($document).toHaveBackdrop(); + + dismiss(modal, 'closing in test'); + + expect($document).toHaveModalsOpen(0); + + expect($document).not.toHaveBackdrop(); + }); + + it('should compile modal before inserting into DOM', function() { + var topModal; + var modalInstance = { + result: $q.defer(), + opened: $q.defer(), + closed: $q.defer(), + rendered: $q.defer(), + close: function (result) { + return $uibModalStack.close(modalInstance, result); + }, + dismiss: function (reason) { + return $uibModalStack.dismiss(modalInstance, reason); + } + }; + var expectedText = 'test'; + + $uibModalStack.open(modalInstance, { + appendTo: angular.element(document.body), + scope: $rootScope.$new(), + deferred: modalInstance.result, + renderDeferred: modalInstance.rendered, + closedDeferred: modalInstance.closed, + content: '{{\'' + expectedText + '\'}}' + }); + + topModal = $uibModalStack.getTop(); + + expect(topModal.value.modalDomEl.find('#test').length).toEqual(0); + expect(angular.element('#test').length).toEqual(0); + + $rootScope.$digest(); + + expect(topModal.value.modalDomEl.find('#test').text()).toEqual(expectedText); + expect(angular.element('#test').text()).toEqual(expectedText); + + $animate.flush(); + + close(modalInstance, 'closing in test', true); + }); + + it('should resolve rendered promise when animation is complete', function() { + var modalInstance = { + result: $q.defer(), + opened: $q.defer(), + closed: $q.defer(), + rendered: $q.defer(), + close: function (result) { + return $uibModalStack.close(modalInstance, result); + }, + dismiss: function (reason) { + return $uibModalStack.dismiss(modalInstance, reason); + } + }; + var rendered = false; + modalInstance.rendered.promise.then(function() { + rendered = true; + }); + + $uibModalStack.open(modalInstance, { + appendTo: angular.element(document.body), + scope: $rootScope.$new(), + deferred: modalInstance.result, + renderDeferred: modalInstance.rendered, + closedDeferred: modalInstance.closed, + content: 'test' + }); + + $rootScope.$digest(); + + expect(rendered).toBe(false); + + $animate.flush(); + + expect(rendered).toBe(true); + }); + + it('should not throw an exception on a second dismiss', function() { + var modal = open({template: 'Content'}); + + expect($document).toHaveModalsOpen(1); + expect($document).toHaveModalOpenWithContent('Content', 'div'); + expect($document).toHaveBackdrop(); + + dismiss(modal, 'closing in test'); + + expect($document).toHaveModalsOpen(0); + + dismiss(modal, 'closing in test', true); + }); + + it('should not throw an exception on a second close', function() { + var modal = open({template: 'Content'}); + + expect($document).toHaveModalsOpen(1); + expect($document).toHaveModalOpenWithContent('Content', 'div'); + expect($document).toHaveBackdrop(); + + close(modal, 'closing in test'); + + expect($document).toHaveModalsOpen(0); + + close(modal, 'closing in test', true); + }); + + it('should open a modal from templateUrl', function() { + $templateCache.put('content.html', 'URL Content'); + var modal = open({templateUrl: 'content.html'}); + + expect($document).toHaveModalsOpen(1); + expect($document).toHaveModalOpenWithContent('URL Content', 'div'); + expect($document).toHaveBackdrop(); + + dismiss(modal, 'closing in test'); + + expect($document).toHaveModalsOpen(0); + + expect($document).not.toHaveBackdrop(); + }); + + it('should support closing on ESC', function() { + var modal = open({template: 'Content'}); + expect($document).toHaveModalsOpen(1); + + triggerKeyDown($document, 27); + $animate.flush(); + $rootScope.$digest(); + $animate.flush(); + $rootScope.$digest(); + + expect($document).toHaveModalsOpen(0); + }); + + it('should not close on ESC if event.preventDefault() was issued', function() { + var modal = open({template: 'x' }); + expect($document).toHaveModalsOpen(1); + + var button = angular.element('button').on('keydown', preventKeyDown); + + triggerKeyDown(button, 27); + $rootScope.$digest(); + + expect($document).toHaveModalsOpen(1); + + button.off('keydown', preventKeyDown); + + triggerKeyDown(button, 27); + $animate.flush(); + $rootScope.$digest(); + $animate.flush(); + $rootScope.$digest(); + + expect($document).toHaveModalsOpen(0); + + function preventKeyDown(evt) { + evt.preventDefault(); + } + }); + + it('should support closing on backdrop click', function() { + var modal = open({template: 'Content'}); + expect($document).toHaveModalsOpen(1); + + $document.find('body > div.modal').click(); + $animate.flush(); + $rootScope.$digest(); + $animate.flush(); + $rootScope.$digest(); + + expect($document).toHaveModalsOpen(0); + }); + + it('should return to the element which had focus before the dialog was invoked', function() { + var link = 'Link'; + var element = angular.element(link); + angular.element(document.body).append(element); + element.focus(); + expect(document.activeElement.tagName).toBe('A'); + + var modal = open({template: 'Contentinside modal'}); + $rootScope.$digest(); + expect(document.activeElement.className.split(' ')).toContain('modal'); + expect($document).toHaveModalsOpen(1); + + triggerKeyDown($document, 27); + $animate.flush(); + $rootScope.$digest(); + $animate.flush(); + $rootScope.$digest(); + + expect(document.activeElement.tagName).toBe('A'); + expect($document).toHaveModalsOpen(0); + + element.remove(); + }); + + it('should return to document.body if element which had focus before the dialog was invoked is gone, or is missing focus function', function() { + var link = 'Link'; + var element = angular.element(link); + angular.element(document.body).append(element); + element.focus(); + expect(document.activeElement.tagName).toBe('A'); + + var modal = open({template: 'Content'}); + $rootScope.$digest(); + expect(document.activeElement.tagName).toBe('DIV'); + expect($document).toHaveModalsOpen(1); + + // Fake undefined focus function, happening in IE in certain + // iframe conditions. See issue 3639 + element[0].focus = undefined; + triggerKeyDown($document, 27); + $animate.flush(); + $rootScope.$digest(); + $animate.flush(); + $rootScope.$digest(); + + expect(document.activeElement.tagName).toBe('BODY'); + expect($document).toHaveModalsOpen(0); + element.remove(); + }); + + it('should resolve returned promise on close', function() { + var modal = open({template: 'Content'}); + close(modal, 'closed ok'); + + expect(modal.result).toBeResolvedWith('closed ok'); + }); + + it('should reject returned promise on dismiss', function() { + var modal = open({template: 'Content'}); + dismiss(modal, 'esc'); + + expect(modal.result).toBeRejectedWith('esc'); + }); + + it('should reject returned promise on unexpected closure', function() { + var scope = $rootScope.$new(); + var modal = open({template: 'Content', scope: scope}); + scope.$destroy(); + + expect(modal.result).toBeRejectedWith('$uibUnscheduledDestruction'); + + $animate.flush(); + $rootScope.$digest(); + $animate.flush(); + $rootScope.$digest(); + expect($document).toHaveModalsOpen(0); + }); + + it('should resolve the closed promise when modal is closed', function() { + var modal = open({template: 'Content'}); + var closed = false; + close(modal, 'closed ok'); + + modal.closed.then(function() { + closed = true; + }); + + $rootScope.$digest(); + + expect(closed).toBe(true); + }); + + it('should resolve the closed promise when modal is dismissed', function() { + var modal = open({template: 'Content'}); + var closed = false; + dismiss(modal, 'esc'); + + modal.closed.then(function() { + closed = true; + }); + + $rootScope.$digest(); + + expect(closed).toBe(true); + }); + + it('should expose a promise linked to the templateUrl / resolve promises', function() { + var modal = open({template: 'Content', resolve: { + ok: function() {return $q.when('ok');} + }} + ); + expect(modal.opened).toBeResolvedWith(true); + }); + + it('should expose a promise linked to the templateUrl / resolve promises and reject it if needed', function() { + var modal = open({template: 'Content', resolve: { + ok: function() {return $q.reject('ko');} + }}, true); + expect(modal.opened).toBeRejectedWith('ko'); + }); + + it('should focus on the element that has autofocus attribute when the modal is open/reopen and the animations have finished', function() { + function openAndCloseModalWithAutofocusElement() { + var modal = open({template: ''}); + $rootScope.$digest(); + expect(angular.element('#auto-focus-element')).toHaveFocus(); + + close(modal, 'closed ok'); + + expect(modal.result).toBeResolvedWith('closed ok'); + } + + openAndCloseModalWithAutofocusElement(); + openAndCloseModalWithAutofocusElement(); + }); + + it('should not focus on the element that has autofocus attribute when the modal is opened and something in the modal already has focus and the animations have finished', function() { + function openAndCloseModalWithAutofocusElement() { + + var modal = open({template: ''}); + $rootScope.$digest(); + expect(angular.element('#auto-focus-element')).not.toHaveFocus(); + expect(angular.element('#pre-focus-element')).toHaveFocus(); + + close(modal, 'closed ok'); + + expect(modal.result).toBeResolvedWith('closed ok'); + } + + openAndCloseModalWithAutofocusElement(); + openAndCloseModalWithAutofocusElement(); + }); + + it('should wait until the in animation is finished before attempting to focus the modal or autofocus element', function() { + function openAndCloseModalWithAutofocusElement() { + var modal = open({template: ''}, true, true); + expect(angular.element('#auto-focus-element')).not.toHaveFocus(); + + $rootScope.$digest(); + $animate.flush(); + + expect(angular.element('#auto-focus-element')).toHaveFocus(); + + close(modal, 'closed ok'); + + expect(modal.result).toBeResolvedWith('closed ok'); + } + + function openAndCloseModalWithOutAutofocusElement() { + var link = 'Link'; + var element = angular.element(link); + angular.element(document.body).append(element); + element.focus(); + expect(document.activeElement.tagName).toBe('A'); + + var modal = open({template: ''}, true, true); + expect(document.activeElement.tagName).toBe('A'); + + $rootScope.$digest(); + $animate.flush(); + + expect(document.activeElement.className.split(' ')).toContain('modal'); + + close(modal, 'closed ok'); + + expect(modal.result).toBeResolvedWith('closed ok'); + + element.remove(); + } + + openAndCloseModalWithAutofocusElement(); + openAndCloseModalWithOutAutofocusElement(); + }); + + it('should change focus to first element when tab key was pressed', function() { + var initialPage = angular.element('Outland link'); + angular.element(document.body).append(initialPage); + initialPage.focus(); + + open({ + template:'' + + 'Open me!' + }); + expect($document).toHaveModalsOpen(1); + + var lastElement = angular.element(document.getElementById('tab-focus-button')); + lastElement.focus(); + triggerKeyDown(lastElement, 9); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link'); + + initialPage.remove(); + }); + + it('should change focus to last element when shift+tab key is pressed', function() { + var initialPage = angular.element('Outland link'); + angular.element(document.body).append(initialPage); + initialPage.focus(); + + open({ + template:'' + + 'Open me!' + }); + $rootScope.$digest(); + expect($document).toHaveModalsOpen(1); + + triggerKeyDown(angular.element(document.activeElement), 9, true); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-button'); + + var lastElement = angular.element(document.getElementById('tab-focus-link')); + lastElement.focus(); + triggerKeyDown(angular.element(document.activeElement), 9, true); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-button'); + + initialPage.remove(); + }); + + it('should change focus to first element when tab key is pressed when keyboard is false', function() { + var initialPage = angular.element('Outland link'); + angular.element(document.body).append(initialPage); + initialPage.focus(); + + open({ + template:'' + + 'Open me!', + keyboard: false + }); + expect($document).toHaveModalsOpen(1); + + var lastElement = angular.element(document.getElementById('tab-focus-button')); + lastElement.focus(); + triggerKeyDown(lastElement, 9); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link'); + + initialPage.remove(); + }); + + it('should change focus to last element when shift+tab keys are pressed when keyboard is false', function() { + var initialPage = angular.element('Outland link'); + angular.element(document.body).append(initialPage); + initialPage.focus(); + + open({ + template:'' + + 'Open me!', + keyboard: false + }); + $rootScope.$digest(); + expect($document).toHaveModalsOpen(1); + + triggerKeyDown(angular.element(document.activeElement), 9, true); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-button'); + + var lastElement = angular.element(document.getElementById('tab-focus-link')); + lastElement.focus(); + triggerKeyDown(angular.element(document.activeElement), 9, true); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-button'); + + initialPage.remove(); + }); + + it('should change focus to next proper element when DOM changes and tab is pressed', function() { + var initialPage = angular.element('Outland link'); + angular.element(document.body).append(initialPage); + initialPage.focus(); + + open({ + template:'abc' + + 'Open me!', + keyboard: false + }); + $rootScope.$digest(); + expect($document).toHaveModalsOpen(1); + + $('#tab-focus-link3').focus(); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link3'); + + $('#tab-focus-button').remove(); + triggerKeyDown(angular.element(document.activeElement), 9, false); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link1'); + + initialPage.remove(); + }); + + it('should change focus to next proper element when DOM changes and shift+tab is pressed', function() { + var initialPage = angular.element('Outland link'); + angular.element(document.body).append(initialPage); + initialPage.focus(); + + open({ + template:'abc' + + 'Open me!', + keyboard: false + }); + $rootScope.$digest(); + expect($document).toHaveModalsOpen(1); + + $('#tab-focus-link1').focus(); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link1'); + + $('#tab-focus-button').remove(); + triggerKeyDown(angular.element(document.activeElement), 9, true); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link3'); + + initialPage.remove(); + }); + + it('should change focus to next non-hidden element when tab is pressed', function() { + var initialPage = angular.element('Outland link'); + angular.element(document.body).append(initialPage); + initialPage.focus(); + + open({ + template:'abc' + + 'Open me!', + keyboard: false + }); + $rootScope.$digest(); + expect($document).toHaveModalsOpen(1); + + $('#tab-focus-link3').focus(); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link3'); + + $('#tab-focus-button').css('display', 'none'); + triggerKeyDown(angular.element(document.activeElement), 9, false); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link1'); + + initialPage.remove(); + }); + + it('should change focus to previous non-hidden element when shift+tab is pressed', function() { + var initialPage = angular.element('Outland link'); + angular.element(document.body).append(initialPage); + initialPage.focus(); + + open({ + template:'abc' + + 'Open me!', + keyboard: false + }); + $rootScope.$digest(); + expect($document).toHaveModalsOpen(1); + + $('#tab-focus-link1').focus(); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link1'); + + $('#tab-focus-button').css('display', 'none'); + triggerKeyDown(angular.element(document.activeElement), 9, true); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link3'); + + initialPage.remove(); + }); + + it('should change focus to next tabbable element when tab is pressed', function() { + var initialPage = angular.element('Outland link'); + angular.element(document.body).append(initialPage); + initialPage.focus(); + + open({ + template:'Skip me!a' + + 'bc' + + 'Skip me!', + keyboard: false + }); + $rootScope.$digest(); + expect($document).toHaveModalsOpen(1); + + $('#tab-focus-link3').focus(); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link3'); + + triggerKeyDown(angular.element(document.activeElement), 9, false); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link1'); + + initialPage.remove(); + }); + + it('should change focus to previous tabbable element when shift+tab is pressed', function() { + var initialPage = angular.element('Outland link'); + angular.element(document.body).append(initialPage); + initialPage.focus(); + + open({ + template:'Skip me!a' + + 'bc' + + 'Skip me!', + keyboard: false + }); + $rootScope.$digest(); + expect($document).toHaveModalsOpen(1); + + $('#tab-focus-link1').focus(); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link1'); + + triggerKeyDown(angular.element(document.activeElement), 9, true); + expect(document.activeElement.getAttribute('id')).toBe('tab-focus-link3'); + + initialPage.remove(); + }); + }); + + describe('default options can be changed in a provider', function() { + it('should allow overriding default options in a provider', function() { + $uibModalProvider.options.backdrop = false; + var modal = open({template: 'Content'}); + + expect($document).toHaveModalOpenWithContent('Content', 'div'); + expect($document).not.toHaveBackdrop(); + }); + + it('should accept new objects with default options in a provider', function() { + $uibModalProvider.options = { + backdrop: false + }; + var modal = open({template: 'Content'}); + + expect($document).toHaveModalOpenWithContent('Content', 'div'); + expect($document).not.toHaveBackdrop(); + }); + }); + + describe('option by option', function() { + describe('component', function() { + function getModalComponent($document) { + return $document.find('body > div.modal > div.modal-dialog > div.modal-content foo-bar'); + } + + it('should use as modal content', function() { + open({ + component: 'fooBar' + }); + + var component = getModalComponent($document); + expect(component.html()).toBe('Foo Bar'); + }); + + it('should bind expected values', function() { + var modal = open({ + component: 'fooBar', + resolve: { + foo: function() { + return 'bar'; + } + } + }); + + var component = getModalComponent($document); + var componentScope = component.isolateScope(); + + expect(componentScope.foobar.resolve.foo).toBe('bar'); + expect(componentScope.foobar.modalInstance).toBe(modal); + expect(componentScope.foobar.close).toEqual(jasmine.any(Function)); + expect(componentScope.foobar.dismiss).toEqual(jasmine.any(Function)); + }); + + it('should close the modal', function() { + var modal = open({ + component: 'fooBar', + resolve: { + foo: function() { + return 'bar'; + } + } + }); + + var component = getModalComponent($document); + var componentScope = component.isolateScope(); + + componentScope.foobar.close({ + $value: 'baz' + }); + + expect(modal.result).toBeResolvedWith('baz'); + }); + + it('should dismiss the modal', function() { + var modal = open({ + component: 'fooBar', + resolve: { + foo: function() { + return 'bar'; + } + } + }); + + var component = getModalComponent($document); + var componentScope = component.isolateScope(); + + componentScope.foobar.dismiss({ + $value: 'baz' + }); + + expect(modal.result).toBeRejectedWith('baz'); + }); + }); + + describe('template and templateUrl', function() { + it('should throw an error if none of component, template and templateUrl are provided', function() { + expect(function(){ + var modal = open({}); + }).toThrow(new Error('One of component or template or templateUrl options is required.')); + }); + + it('should not fail if a templateUrl contains leading / trailing white spaces', function() { + $templateCache.put('whitespace.html', ' Whitespaces '); + open({templateUrl: 'whitespace.html'}); + expect($document).toHaveModalOpenWithContent('Whitespaces', 'div'); + }); + + it('should accept template as a function', function() { + open({template: function() { + return 'From a function'; + }}); + + expect($document).toHaveModalOpenWithContent('From a function', 'div'); + }); + + it('should not fail if a templateUrl as a function', function() { + + $templateCache.put('whitespace.html', ' Whitespaces '); + open({templateUrl: function() { + return 'whitespace.html'; + }}); + expect($document).toHaveModalOpenWithContent('Whitespaces', 'div'); + }); + }); + + describe('controller', function() { + it('should accept controllers and inject modal instances', function() { + var TestCtrl = function($scope, $uibModalInstance) { + $scope.fromCtrl = 'Content from ctrl'; + $scope.isModalInstance = angular.isObject($uibModalInstance) && angular.isFunction($uibModalInstance.close); + }; + + open({template: '{{fromCtrl}} {{isModalInstance}}', controller: TestCtrl}); + expect($document).toHaveModalOpenWithContent('Content from ctrl true', 'div'); + }); + + it('should accept controllerAs alias', function() { + $controllerProvider.register('TestCtrl', function($uibModalInstance) { + this.fromCtrl = 'Content from ctrl'; + this.isModalInstance = angular.isObject($uibModalInstance) && angular.isFunction($uibModalInstance.close); + }); + + open({template: '{{test.fromCtrl}} {{test.isModalInstance}}', controller: 'TestCtrl as test'}); + expect($document).toHaveModalOpenWithContent('Content from ctrl true', 'div'); + }); + + it('should respect the controllerAs property as an alternative for the controller-as syntax', function() { + $controllerProvider.register('TestCtrl', function($uibModalInstance) { + this.fromCtrl = 'Content from ctrl'; + this.isModalInstance = angular.isObject($uibModalInstance) && angular.isFunction($uibModalInstance.close); + }); + + open({template: '{{test.fromCtrl}} {{test.isModalInstance}}', controller: 'TestCtrl', controllerAs: 'test'}); + expect($document).toHaveModalOpenWithContent('Content from ctrl true', 'div'); + }); + + it('should allow defining in-place controller-as controllers', function() { + open({template: '{{test.fromCtrl}} {{test.isModalInstance}}', controller: function($uibModalInstance) { + this.fromCtrl = 'Content from ctrl'; + this.isModalInstance = angular.isObject($uibModalInstance) && angular.isFunction($uibModalInstance.close); + }, controllerAs: 'test'}); + expect($document).toHaveModalOpenWithContent('Content from ctrl true', 'div'); + }); + + it('should allow usage of bindToController', function() { + var $scope = $rootScope.$new(true); + $scope.foo = 'bar'; + open({ + template: '{{test.fromCtrl}} {{test.closeDismissPresent()}} {{test.foo}}', + controller: function($uibModalInstance) { + expect(this.foo).toEqual($scope.foo); + this.fromCtrl = 'Content from ctrl'; + this.closeDismissPresent = function() { + return angular.isFunction(this.$close) && angular.isFunction(this.$dismiss); + }; + }, + controllerAs: 'test', + bindToController: true, + scope: $scope + }); + expect($document).toHaveModalOpenWithContent('Content from ctrl true bar', 'div'); + }); + + it('should have $onInit called', function() { + var $scope = $rootScope.$new(true); + var $onInit = jasmine.createSpy('$onInit'); + $scope.foo = 'bar'; + open({ + template: '{{test.fromCtrl}} {{test.closeDismissPresent()}} {{test.foo}}', + controller: function($uibModalInstance) { + this.$onInit = $onInit; + this.fromCtrl = 'Content from ctrl'; + this.closeDismissPresent = function() { + return angular.isFunction(this.$close) && angular.isFunction(this.$dismiss); + }; + }, + controllerAs: 'test', + bindToController: true, + scope: $scope + }); + expect($document).toHaveModalOpenWithContent('Content from ctrl true bar', 'div'); + expect($onInit).toHaveBeenCalled(); + }); + }); + + describe('resolve', function() { + var ExposeCtrl = function($scope, value) { + $scope.value = value; + }; + + function modalDefinition(template, resolve) { + return { + template: template, + controller: ExposeCtrl, + resolve: resolve + }; + } + + it('should resolve simple values', function() { + open(modalDefinition('{{value}}', { + value: function() { + return 'Content from resolve'; + } + })); + + expect($document).toHaveModalOpenWithContent('Content from resolve', 'div'); + }); + + it('should resolve string references to injectables', function() { + open({ + controller: function($scope, $foo) { + $scope.value = 'Content from resolve'; + expect($foo).toBe($uibModal); + }, + resolve: { + $foo: '$uibModal' + }, + template: '{{value}}' + }); + + expect($document).toHaveModalOpenWithContent('Content from resolve', 'div'); + }); + + it('should resolve promises as promises', function() { + open({ + controller: function($scope, $foo) { + $scope.value = 'Content from resolve'; + expect($foo).toBe('bar'); + }, + resolve: { + $foo: $q.when('bar') + }, + template: '{{value}}' + }); + }); + + it('should delay showing modal if one of the resolves is a promise', function() { + open(modalDefinition('{{value}}', { + value: function() { + return $timeout(function() { return 'Promise'; }, 100); + } + }), true); + expect($document).toHaveModalsOpen(0); + + $timeout.flush(); + expect($document).toHaveModalOpenWithContent('Promise', 'div'); + }); + + it('should not open dialog (and reject returned promise) if one of resolve fails', function() { + var deferred = $q.defer(); + + var modal = open(modalDefinition('{{value}}', { + value: function() { + return deferred.promise; + } + }), true); + expect($document).toHaveModalsOpen(0); + + deferred.reject('error in test'); + $rootScope.$digest(); + + expect($document).toHaveModalsOpen(0); + expect(modal.result).toBeRejectedWith('error in test'); + }); + + it('should support injection with minification-safe syntax in resolve functions', function() { + open(modalDefinition('{{value.id}}', { + value: ['$locale', function(e) { + return e; + }] + })); + + expect($document).toHaveModalOpenWithContent('en-us', 'div'); + }); + }); + + describe('scope', function() { + it('should use custom scope if provided', function() { + var $scope = $rootScope.$new(); + $scope.fromScope = 'Content from custom scope'; + open({ + template: '{{fromScope}}', + scope: $scope + }); + expect($document).toHaveModalOpenWithContent('Content from custom scope', 'div'); + }); + + it('should create and use child of $rootScope if custom scope not provided', function() { + var scopeTailBefore = $rootScope.$$childTail; + + $rootScope.fromScope = 'Content from root scope'; + open({ + template: '{{fromScope}}' + }); + expect($document).toHaveModalOpenWithContent('Content from root scope', 'div'); + }); + + it('should expose $resolve in template', function() { + open({ + controller: function($scope) {}, + resolve: { + $foo: function() { + return 'Content from resolve'; + } + }, + template: '{{$resolve.$foo}}' + }); + + expect($document).toHaveModalOpenWithContent('Content from resolve', 'div'); + }); + }); + + describe('keyboard', function () { + it('should not close modals if keyboard option is set to false', function() { + open({ + template: 'No keyboard', + keyboard: false + }); + + expect($document).toHaveModalsOpen(1); + + triggerKeyDown($document, 27); + $rootScope.$digest(); + + expect($document).toHaveModalsOpen(1); + }); + }); + + describe('backdrop', function() { + it('should not have any backdrop element if backdrop set to false', function() { + var modal = open({ + template: 'No backdrop', + backdrop: false + }); + expect($document).toHaveModalOpenWithContent('No backdrop', 'div'); + expect($document).not.toHaveBackdrop(); + + dismiss(modal); + expect($document).toHaveModalsOpen(0); + }); + + it('should not close modal on backdrop click if backdrop is specified as "static"', function() { + open({ + template: 'Static backdrop', + backdrop: 'static' + }); + + $document.find('body > div.modal-backdrop').click(); + $rootScope.$digest(); + + expect($document).toHaveModalOpenWithContent('Static backdrop', 'div'); + expect($document).toHaveBackdrop(); + }); + + it('should contain backdrop in classes on each modal opening', function() { + var modal = open({ template: 'With backdrop' }); + var backdropEl = $document.find('body > div.modal-backdrop'); + expect(backdropEl).toHaveClass('in'); + + dismiss(modal); + + modal = open({ template: 'With backdrop' }); + backdropEl = $document.find('body > div.modal-backdrop'); + expect(backdropEl).toHaveClass('in'); + + }); + + describe('custom backdrop classes', function () { + it('should support additional backdrop class as string', function() { + open({ + template: 'With custom backdrop class', + backdropClass: 'additional' + }); + + expect($document.find('div.modal-backdrop')).toHaveClass('additional'); + }); + }); + }); + + describe('custom window classes', function() { + it('should support additional window class as string', function() { + open({ + template: 'With custom window class', + windowClass: 'additional' + }); + + expect($document.find('div.modal')).toHaveClass('additional'); + }); + }); + + describe('top window class', function () { + it('should support top class option', function () { + open({ + template: 'With custom window top class', + windowTopClass: 'top-class' + }); + + expect($document.find('div.modal')).toHaveClass('top-class'); + }); + }); + + describe('size', function() { + it('should support creating small modal dialogs', function() { + open({ + template: 'Small modal dialog', + size: 'sm' + }); + + expect($document.find('div.modal-dialog')).toHaveClass('modal-sm'); + }); + + it('should support creating large modal dialogs', function() { + open({ + template: 'Large modal dialog', + size: 'lg' + }); + + expect($document.find('div.modal-dialog')).toHaveClass('modal-lg'); + }); + + it('should support custom size modal dialogs', function() { + open({ + template: 'Large modal dialog', + size: 'custom' + }); + + expect($document.find('div.modal-dialog')).toHaveClass('modal-custom'); + }); + }); + + describe('animation', function() { + it('should have animation fade classes by default', function() { + open({ + template: 'Small modal dialog' + }); + + expect($document.find('.modal')).toHaveClass('fade'); + expect($document.find('.modal-backdrop')).toHaveClass('fade'); + }); + + it('should not have fade classes if animation false', function() { + open({ + template: 'Small modal dialog', + animation: false + }); + + expect($document.find('.modal')).not.toHaveClass('fade'); + expect($document.find('.modal-backdrop')).not.toHaveClass('fade'); + }); + }); + + describe('appendTo', function() { + it('should be added to body by default', function() { + var modal = open({template: 'Content'}); + + expect($document).toHaveModalsOpen(1); + expect($document).toHaveModalOpenWithContent('Content', 'div'); + }); + + it('should not be added to body if appendTo is passed', function() { + var element = angular.element('Some content'); + angular.element(document.body).append(element); + + var modal = open({template: 'Content', appendTo: element}); + + expect($document).not.toHaveModalOpenWithContent('Content', 'div'); + + element.remove(); + }); + + it('should be added to appendTo element if appendTo is passed', function() { + var element = angular.element('Some content'); + angular.element(document.body).append(element); + + expect($document.find('section').children('div.modal').length).toBe(0); + open({template: 'Content', appendTo: element}); + expect($document.find('section').children('div.modal').length).toBe(1); + + element.remove(); + }); + + it('should throw error if appendTo element is not found', function() { + expect(function(){ + open({template: 'Content', appendTo: $document.find('aside')}); + }).toThrow(new Error('appendTo element not found. Make sure that the element passed is in DOM.')); + }); + + it('should be removed from appendTo element when dismissed', function() { + var modal = open({template: 'Content'}); + + expect($document).toHaveModalsOpen(1); + + dismiss(modal); + expect($document).toHaveModalsOpen(0); + }); + + it('should allow requiring parent directive from appendTo target', function() { + var element = $compile('Some content')($rootScope); + angular.element(document.body).append(element); + + open({template: '{{text}}', appendTo: element}); + expect($document.find('[child-directive]').text()).toBe('foo'); + + element.remove(); + }); + }); + + describe('openedClass', function() { + var body; + + beforeEach(function() { + body = $document.find('body'); + }); + + it('should add the modal-open class to the body element by default', function() { + open({ + template: 'dummy modal' + }); + + expect(body).toHaveClass('modal-open'); + }); + + it('should add the custom class to the body element', function() { + open({ + template: 'dummy modal', + openedClass: 'foo' + }); + + expect(body).toHaveClass('foo'); + expect(body).not.toHaveClass('modal-open'); + }); + + it('should remove the custom class on closing of modal after animations have completed', function() { + var modal = open({ + template: 'dummy modal', + openedClass: 'foo' + }); + + expect(body).toHaveClass('foo'); + + close(modal, null, true); + expect(body).toHaveClass('foo'); + + $animate.flush(); + $rootScope.$digest(); + $animate.flush(); + $rootScope.$digest(); + + expect(body).not.toHaveClass('foo'); + }); + + it('should add multiple custom classes to the body element and remove appropriately', function() { + var modal1 = open({ + template: 'dummy modal', + openedClass: 'foo' + }); + + expect(body).toHaveClass('foo'); + expect(body).not.toHaveClass('modal-open'); + + var modal2 = open({ + template: 'dummy modal', + openedClass: 'bar' + }); + + expect(body).toHaveClass('foo'); + expect(body).toHaveClass('bar'); + expect(body).not.toHaveClass('modal-open'); + + var modal3 = open({ + template: 'dummy modal', + openedClass: 'foo' + }); + + expect(body).toHaveClass('foo'); + expect(body).toHaveClass('bar'); + expect(body).not.toHaveClass('modal-open'); + + close(modal1); + + expect(body).toHaveClass('foo'); + expect(body).toHaveClass('bar'); + expect(body).not.toHaveClass('modal-open'); + + close(modal2); + + expect(body).toHaveClass('foo'); + expect(body).not.toHaveClass('bar'); + expect(body).not.toHaveClass('modal-open'); + + close(modal3); + + expect(body).not.toHaveClass('foo'); + expect(body).not.toHaveClass('bar'); + expect(body).not.toHaveClass('modal-open'); + }); + + it('should not add the modal-open class if modal is closed before animation', function() { + var modal = open({ + template: 'dummy modal' + }, true); + + close(modal); + + expect(body).not.toHaveClass('modal-open'); + }); + }); + + describe('ariaLabelledBy', function() { + it('should add the aria-labelledby property to the modal', function() { + open({ + template: 'Modal LabelModal description', + ariaLabelledBy: 'modal-label' + }); + + expect($document.find('.modal').attr('aria-labelledby')).toEqual('modal-label'); + }); + }); + + describe('ariaDescribedBy', function() { + it('should add the aria-describedby property to the modal', function() { + open({ + template: 'Modal LabelModal description', + ariaDescribedBy: 'modal-description' + }); + + expect($document.find('.modal').attr('aria-describedby')).toEqual('modal-description'); + }); + }); + }); + + describe('modal window', function() { + it('should not use transclusion scope for modals content - issue 2110', function() { + $rootScope.animate = false; + $compile('')($rootScope); + $rootScope.$digest(); + + expect($rootScope.foo).toBeTruthy(); + }); + + it('should support window top class', function () { + $rootScope.animate = false; + var windowEl = $compile('content')($rootScope); + $rootScope.$digest(); + + expect(windowEl).toHaveClass('test'); + expect(windowEl).toHaveClass('foo'); + }); + + it('should support custom template url', inject(function($templateCache) { + $templateCache.put('window.html', ''); + + var windowEl = $compile('content')($rootScope); + $rootScope.$digest(); + + expect(windowEl.html()).toBe('content'); + })); + }); + + describe('multiple modals', function() { + it('should allow opening of multiple modals', function() { + var modal1 = open({template: 'Modal1'}); + var modal2 = open({template: 'Modal2'}); + expect($document).toHaveModalsOpen(2); + + dismiss(modal2); + expect($document).toHaveModalsOpen(1); + expect($document).toHaveModalOpenWithContent('Modal1', 'div'); + + dismiss(modal1); + expect($document).toHaveModalsOpen(0); + }); + + it('should be able to dismiss all modals at once', function() { + var modal1 = open({template: 'Modal1'}); + var modal2 = open({template: 'Modal2'}); + expect($document).toHaveModalsOpen(2); + + $uibModalStack.dismissAll(); + $animate.flush(); + $animate.flush(); + expect($document).toHaveModalsOpen(0); + }); + + it('should not close any modals on ESC if the topmost one does not allow it', function() { + var modal1 = open({template: 'Modal1'}); + var modal2 = open({template: 'Modal2', keyboard: false}); + + triggerKeyDown($document, 27); + $rootScope.$digest(); + + expect($document).toHaveModalsOpen(2); + }); + + it('should not close any modals on click if a topmost modal does not have backdrop', function() { + var modal1 = open({template: 'Modal1'}); + var modal2 = open({template: 'Modal2', backdrop: false}); + + $document.find('body > div.modal-backdrop').click(); + $rootScope.$digest(); + + expect($document).toHaveModalsOpen(2); + }); + + it('should not interfere with default options', function() { + var modal1 = open({template: 'Modal1', backdrop: false}); + var modal2 = open({template: 'Modal2'}); + $rootScope.$digest(); + + expect($document).toHaveBackdrop(); + }); + + it('should add "modal-open" class when a modal gets opened', function() { + var body = $document.find('body'); + expect(body).not.toHaveClass('modal-open'); + + var modal1 = open({template: 'Content1'}); + expect(body).toHaveClass('modal-open'); + + var modal2 = open({template: 'Content1'}); + expect(body).toHaveClass('modal-open'); + + dismiss(modal1); + expect(body).toHaveClass('modal-open'); + + dismiss(modal2); + expect(body).not.toHaveClass('modal-open'); + }); + + it('should return to the element which had focus before the dialog is invoked', function() { + var link = 'Link'; + var element = angular.element(link); + angular.element(document.body).append(element); + element.focus(); + expect(document.activeElement.tagName).toBe('A'); + + var modal1 = open({template: 'Modal1inside modal1'}); + $rootScope.$digest(); + document.getElementById('focus').focus(); + expect(document.activeElement.tagName).toBe('BUTTON'); + expect($document).toHaveModalsOpen(1); + + var modal2 = open({template: 'Modal2'}); + $rootScope.$digest(); + expect(document.activeElement.tagName).toBe('DIV'); + expect($document).toHaveModalsOpen(2); + + dismiss(modal2); + expect(document.activeElement.tagName).toBe('BUTTON'); + expect($document).toHaveModalsOpen(1); + + dismiss(modal1); + expect(document.activeElement.tagName).toBe('A'); + expect($document).toHaveModalsOpen(0); + + element.remove(); + }); + + it('should open modals and resolve the opened promises in order', function() { + // Opens a modal for each element in array order. + // Order is an array of non-repeating integers from 0..length-1 representing when to resolve that modal's promise. + // For example [1,2,0] would resolve the 3rd modal's promise first and the 2nd modal's promise last. + // Tests that the modals are added to $uibModalStack and that each resolves its "opened" promise sequentially. + // If an element is {reject:n} then n is still the order, but the corresponding promise is rejected. + // A rejection earlier in the open sequence should not affect modals opened later. + function test(order) { + var ds = []; // {index, deferred, reject} + var expected = ''; // 0..length-1 + var actual = ''; + angular.forEach(order, function(x, i) { + var reject = x.reject !== undefined; + if (reject) { + x = x.reject; + } else { + expected += i; + } + ds[x] = {index: i, deferred: $q.defer(), reject: reject}; + + var scope = $rootScope.$new(); + var failed = false; + scope.index = i; + open({ + template: '' + i + '', + scope: scope, + resolve: { + x: function() { return ds[x].deferred.promise['catch'](function () { + failed = true; + }); } + } + }, true).opened.then(function() { + expect($uibModalStack.getTop().value.modalScope.index).toEqual(i); + if (!failed) { actual += i; } + }); + }); + + angular.forEach(ds, function(d, i) { + if (d.reject) { + d.deferred.reject('rejected:' + d.index); + } else { + d.deferred.resolve('resolved:' + d.index); + } + $rootScope.$digest(); + }); + + expect(actual).toEqual(expected); + expect($uibModal.getPromiseChain()).toEqual(null); + } + + // Calls emit n! times on arrays of length n containing all non-repeating permutations of the integers 0..n-1. + function permute(n, emit) { + if (n < 1 || typeof emit !== 'function') { + return; + } + var a = []; + function _permute(depth) { + index: for (var i = 0; i < n; i++) { + for (var j = 0; j < depth; j++) { + if (a[j] === i) { + continue index; // already used + } + } + + a[depth] = i; + if (depth + 1 === n) { + emit(angular.copy(a)); + } else { + _permute(depth + 1); + } + } + } + _permute(0); + } + + permute(2, function(a) { + test(a); + }); + permute(2, function(a) { + test(a.map(function(x, i) { + return {reject:x}; + })); + }); + permute(2, function(a) { + test(a.map(function(x, i) { + return i === 0 ? {reject: x} : x; + })); + }); + permute(3, function(a) { + test(a); + }); + permute(3, function(a) { + test(a.map(function(x, i) { + return {reject: x}; + })); + }); + permute(3, function(a) { + test(a.map(function(x, i) { + return i === 0 ? {reject: x} : x; + })); + }); + permute(3, function(a) { + test(a.map(function(x, i) { + return i === 1 ? {reject: x} : x; + })); + }); + + $animate.flush(); + }); + + it('should have top class only on top window', function () { + var modal1 = open({template: 'Content1', windowClass: 'modal1', windowTopClass: 'modal-top'}); + expect($document.find('div.modal1')).toHaveClass('modal-top'); + expect($document).toHaveModalsOpen(1); + + var modal2 = open({template: 'Content1', windowClass: 'modal2', windowTopClass: 'modal-top'}); + expect($document.find('div.modal1')).not.toHaveClass('modal-top'); + expect($document.find('div.modal2')).toHaveClass('modal-top'); + expect($document).toHaveModalsOpen(2); + + var modal3 = open({template: 'Content1', windowClass: 'modal3', windowTopClass: 'modal-top'}); + expect($document.find('div.modal1')).not.toHaveClass('modal-top'); + expect($document.find('div.modal2')).not.toHaveClass('modal-top'); + expect($document.find('div.modal3')).toHaveClass('modal-top'); + expect($document).toHaveModalsOpen(3); + + dismiss(modal2); + expect($document.find('div.modal1')).not.toHaveClass('modal-top'); + expect($document.find('div.modal3')).toHaveClass('modal-top'); + expect($document).toHaveModalsOpen(2); + + close(modal3); + expect($document.find('div.modal1')).toHaveClass('modal-top'); + expect($document).toHaveModalsOpen(1); + }); + + it('should have top modal with highest index', function() { + var modal2Index = null; + var modal3Index = null; + + var modal1Instance = { + result: $q.defer(), + opened: $q.defer(), + closed: $q.defer(), + rendered: $q.defer(), + close: function(result) { + return $uibModalStack.close(modal1Instance, result); + }, + dismiss: function(reason) { + return $uibModalStack.dismiss(modal1Instance, reason); + } + }; + var modal2Instance = { + result: $q.defer(), + opened: $q.defer(), + closed: $q.defer(), + rendered: $q.defer(), + close: function(result) { + return $uibModalStack.close(modal2Instance, result); + }, + dismiss: function(reason) { + return $uibModalStack.dismiss(modal2Instance, reason); + } + }; + var modal3Instance = { + result: $q.defer(), + opened: $q.defer(), + closed: $q.defer(), + rendered: $q.defer(), + close: function(result) { + return $uibModalStack.close(modal13nstance, result); + }, + dismiss: function(reason) { + return $uibModalStack.dismiss(modal3Instance, reason); + } + }; + + var modal1 = $uibModalStack.open(modal1Instance, { + appendTo: angular.element(document.body), + scope: $rootScope.$new(), + deferred: modal1Instance.result, + renderDeferred: modal1Instance.rendered, + closedDeferred: modal1Instance.closed, + content: 'Modal1' + }); + + $rootScope.$digest(); + $animate.flush(); + expect($document).toHaveModalsOpen(1); + + expect(parseInt($uibModalStack.getTop().value.modalDomEl.attr('index'), 10)).toEqual(0); + + var modal2 = $uibModalStack.open(modal2Instance, { + appendTo: angular.element(document.body), + scope: $rootScope.$new(), + deferred: modal2Instance.result, + renderDeferred: modal2Instance.rendered, + closedDeferred: modal2Instance.closed, + content: 'Modal2' + }); + + modal2Instance.rendered.promise.then(function() { + modal2Index = parseInt($uibModalStack.getTop().value.modalDomEl.attr('index'), 10); + }); + + $rootScope.$digest(); + $animate.flush(); + expect($document).toHaveModalsOpen(2); + + expect(modal2Index).toEqual(1); + close(modal1Instance); + expect($document).toHaveModalsOpen(1); + + var modal3 = $uibModalStack.open(modal3Instance, { + appendTo: angular.element(document.body), + scope: $rootScope.$new(), + deferred: modal3Instance.result, + renderDeferred: modal3Instance.rendered, + closedDeferred: modal3Instance.closed, + content: 'Modal3' + }); + + modal3Instance.rendered.promise.then(function() { + modal3Index = parseInt($uibModalStack.getTop().value.modalDomEl.attr('index'), 10); + }); + + $rootScope.$digest(); + $animate.flush(); + expect($document).toHaveModalsOpen(2); + + expect(modal3Index).toEqual(2); + expect(modal2Index).toBeLessThan(modal3Index); + }); + + it('should have top modal with highest z-index', function() { + var modal2zIndex = null; + var modal3zIndex = null; + + var modal1Instance = { + result: $q.defer(), + opened: $q.defer(), + closed: $q.defer(), + rendered: $q.defer(), + close: function(result) { + return $uibModalStack.close(modal1Instance, result); + }, + dismiss: function(reason) { + return $uibModalStack.dismiss(modal1Instance, reason); + } + }; + var modal2Instance = { + result: $q.defer(), + opened: $q.defer(), + closed: $q.defer(), + rendered: $q.defer(), + close: function(result) { + return $uibModalStack.close(modal2Instance, result); + }, + dismiss: function(reason) { + return $uibModalStack.dismiss(modal2Instance, reason); + } + }; + var modal3Instance = { + result: $q.defer(), + opened: $q.defer(), + closed: $q.defer(), + rendered: $q.defer(), + close: function(result) { + return $uibModalStack.close(modal3Instance, result); + }, + dismiss: function(reason) { + return $uibModalStack.dismiss(modal3Instance, reason); + } + }; + + var modal1 = $uibModalStack.open(modal1Instance, { + appendTo: angular.element(document.body), + scope: $rootScope.$new(), + deferred: modal1Instance.result, + renderDeferred: modal1Instance.rendered, + closedDeferred: modal1Instance.closed, + content: 'Modal1' + }); + + $rootScope.$digest(); + $animate.flush(); + expect($document).toHaveModalsOpen(1); + + expect(+$uibModalStack.getTop().value.modalDomEl[0].style.zIndex).toBe(1050); + + var modal2 = $uibModalStack.open(modal2Instance, { + appendTo: angular.element(document.body), + scope: $rootScope.$new(), + deferred: modal2Instance.result, + renderDeferred: modal2Instance.rendered, + closedDeferred: modal2Instance.closed, + content: 'Modal2' + }); + + modal2Instance.rendered.promise.then(function() { + modal2zIndex = +$uibModalStack.getTop().value.modalDomEl[0].style.zIndex; + }); + + $rootScope.$digest(); + $animate.flush(); + expect($document).toHaveModalsOpen(2); + + expect(modal2zIndex).toBe(1060); + close(modal1Instance); + expect($document).toHaveModalsOpen(1); + + var modal3 = $uibModalStack.open(modal3Instance, { + appendTo: angular.element(document.body), + scope: $rootScope.$new(), + deferred: modal3Instance.result, + renderDeferred: modal3Instance.rendered, + closedDeferred: modal3Instance.closed, + content: 'Modal3' + }); + + modal3Instance.rendered.promise.then(function() { + modal3zIndex = +$uibModalStack.getTop().value.modalDomEl[0].style.zIndex; + }); + + $rootScope.$digest(); + $animate.flush(); + expect($document).toHaveModalsOpen(2); + + expect(modal3zIndex).toBe(1070); + expect(modal2zIndex).toBeLessThan(modal3zIndex); + }); + }); + + describe('modal.closing event', function() { + it('should close the modal contingent on the modal.closing event and return whether the modal closed', function() { + var preventDefault; + var modal; + var template = 'content'; + + function TestCtrl($scope) { + $scope.$on('modal.closing', function(event, resultOrReason, closing) { + if (preventDefault) { + event.preventDefault(); + } + }); + } + + modal = open({template: template, controller: TestCtrl}); + + preventDefault = true; + expect(close(modal, 'result', true)).toBeFalsy(); + expect($document).toHaveModalsOpen(1); + + preventDefault = false; + expect(close(modal, 'result')).toBeTruthy(); + expect($document).toHaveModalsOpen(0); + + modal = open({template: template, controller: TestCtrl}); + + preventDefault = true; + expect(dismiss(modal, 'result', true)).toBeFalsy(); + expect($document).toHaveModalsOpen(1); + + preventDefault = false; + expect(dismiss(modal, 'result')).toBeTruthy(); + expect($document).toHaveModalsOpen(0); + }); + + it('should trigger modal.closing and pass result/reason and closing parameters to the event', function() { + var called; + + called = false; + + close(open({ + template: 'content', + controller: function($scope) { + $scope.$on('modal.closing', function(event, resultOrReason, closing) { + called = true; + expect(resultOrReason).toBe('result'); + expect(closing).toBeTruthy(); + }); + } + }), 'result'); + expect(called).toBeTruthy(); + + called = false; + dismiss(open({ + template: 'content', + controller: function($scope) { + $scope.$on('modal.closing', function(event, resultOrReason, closing) { + called = true; + expect(resultOrReason).toBe('reason'); + expect(closing).toBeFalsy(); + }); + } + }), 'reason'); + expect(called).toBeTruthy(); + }); + }); +}); diff --git a/src/multiMap/index.js b/src/multiMap/index.js new file mode 100644 index 0000000000..d3db908589 --- /dev/null +++ b/src/multiMap/index.js @@ -0,0 +1 @@ +require('./multiMap.js'); diff --git a/src/multiMap/multiMap.js b/src/multiMap/multiMap.js new file mode 100644 index 0000000000..47bf6d4639 --- /dev/null +++ b/src/multiMap/multiMap.js @@ -0,0 +1,55 @@ +angular.module('ui.bootstrap.multiMap', []) +/** + * A helper, internal data structure that stores all references attached to key + */ + .factory('$$multiMap', function() { + return { + createNew: function() { + var map = {}; + + return { + entries: function() { + return Object.keys(map).map(function(key) { + return { + key: key, + value: map[key] + }; + }); + }, + get: function(key) { + return map[key]; + }, + hasKey: function(key) { + return !!map[key]; + }, + keys: function() { + return Object.keys(map); + }, + put: function(key, value) { + if (!map[key]) { + map[key] = []; + } + + map[key].push(value); + }, + remove: function(key, value) { + var values = map[key]; + + if (!values) { + return; + } + + var idx = values.indexOf(value); + + if (idx !== -1) { + values.splice(idx, 1); + } + + if (!values.length) { + delete map[key]; + } + } + }; + } + }; + }); diff --git a/src/multiMap/test/multiMap.spec.js b/src/multiMap/test/multiMap.spec.js new file mode 100644 index 0000000000..40345144c9 --- /dev/null +++ b/src/multiMap/test/multiMap.spec.js @@ -0,0 +1,58 @@ +describe('multi map', function() { + var multiMap; + + beforeEach(module('ui.bootstrap.multiMap')); + beforeEach(inject(function($$multiMap) { + multiMap = $$multiMap.createNew(); + })); + + it('should add and remove objects by key', function() { + multiMap.put('foo', 'bar'); + + expect(multiMap.get('foo')).toEqual(['bar']); + + multiMap.put('foo', 'baz'); + + expect(multiMap.get('foo')).toEqual(['bar', 'baz']); + + multiMap.remove('foo', 'bar'); + + expect(multiMap.get('foo')).toEqual(['baz']); + + multiMap.remove('foo', 'baz'); + + expect(multiMap.hasKey('foo')).toBe(false); + }); + + it('should support getting the keys', function() { + multiMap.put('foo', 'bar'); + multiMap.put('baz', 'boo'); + + expect(multiMap.keys()).toEqual(['foo', 'baz']); + }); + + it('should return all entries', function() { + multiMap.put('foo', 'bar'); + multiMap.put('foo', 'bar2'); + multiMap.put('baz', 'boo'); + + expect(multiMap.entries()).toEqual([ + { + key: 'foo', + value: ['bar', 'bar2'] + }, + { + key: 'baz', + value: ['boo'] + } + ]); + }); + + it('should preserve semantic of an empty key', function() { + expect(multiMap.get('key')).toBeUndefined(); + }); + + it('should respect removal of non-existing elements', function() { + expect(multiMap.remove('foo', 'bar')).toBeUndefined(); + }); +}); diff --git a/src/pager/docs/demo.html b/src/pager/docs/demo.html new file mode 100644 index 0000000000..c88db56e31 --- /dev/null +++ b/src/pager/docs/demo.html @@ -0,0 +1,5 @@ + + Pager + You are currently on page {{currentPage}} + + diff --git a/src/pager/docs/demo.js b/src/pager/docs/demo.js new file mode 100644 index 0000000000..b0d0d6cd1f --- /dev/null +++ b/src/pager/docs/demo.js @@ -0,0 +1,4 @@ +angular.module('ui.bootstrap.demo').controller('PagerDemoCtrl', function($scope) { + $scope.totalItems = 64; + $scope.currentPage = 4; +}); diff --git a/src/pager/docs/readme.md b/src/pager/docs/readme.md new file mode 100644 index 0000000000..2889380c3f --- /dev/null +++ b/src/pager/docs/readme.md @@ -0,0 +1,51 @@ +A lightweight pager directive that is focused on providing previous/next paging functionality + +### uib-pager settings + +* `align` + C + _(Default: `true`)_ - + Whether to align each link to the sides. + +* `items-per-page` + $ + C + + _(Default: `10`)_ - + Maximum number of items per page. A value less than one indicates all items on one page. + +* `next-text` + C + _(Default: `Next »`)_ - + Text for Next button. + +* `ng-disabled` + $ + + _(Default: `false`)_ - + Used to disable the pager component. + +* `ng-model` + $ + - + Current page number. First page is 1. + +* `num-pages` + $ + readonly + _(Default: `angular.noop`)_ - + An optional expression assigned the total number of pages to display. + +* `previous-text` + C + _(Default: `« Previous`)_ - + Text for Previous button. + +* `template-url` + _(Default: `uib/template/pager/pager.html`)_ - + Override the template for the component with a custom provided template. + +* `total-items` + $ + - + Total number of items in all pages. diff --git a/src/pager/index.js b/src/pager/index.js new file mode 100644 index 0000000000..8f99dbcf81 --- /dev/null +++ b/src/pager/index.js @@ -0,0 +1,10 @@ +require('../paging'); +require('../tabindex'); +require('../../template/pager/pager.html.js'); +require('./pager'); + +var MODULE_NAME = 'ui.bootstrap.module.pager'; + +angular.module(MODULE_NAME, ['ui.bootstrap.pager', 'uib/template/pager/pager.html']); + +module.exports = MODULE_NAME; diff --git a/src/pager/pager.js b/src/pager/pager.js new file mode 100644 index 0000000000..8906183086 --- /dev/null +++ b/src/pager/pager.js @@ -0,0 +1,42 @@ +angular.module('ui.bootstrap.pager', ['ui.bootstrap.paging', 'ui.bootstrap.tabindex']) + +.controller('UibPagerController', ['$scope', '$attrs', 'uibPaging', 'uibPagerConfig', function($scope, $attrs, uibPaging, uibPagerConfig) { + $scope.align = angular.isDefined($attrs.align) ? $scope.$parent.$eval($attrs.align) : uibPagerConfig.align; + + uibPaging.create(this, $scope, $attrs); +}]) + +.constant('uibPagerConfig', { + itemsPerPage: 10, + previousText: '« Previous', + nextText: 'Next »', + align: true +}) + +.directive('uibPager', ['uibPagerConfig', function(uibPagerConfig) { + return { + scope: { + totalItems: '=', + previousText: '@', + nextText: '@', + ngDisabled: '=' + }, + require: ['uibPager', '?ngModel'], + restrict: 'A', + controller: 'UibPagerController', + controllerAs: 'pager', + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/pager/pager.html'; + }, + link: function(scope, element, attrs, ctrls) { + element.addClass('pager'); + var paginationCtrl = ctrls[0], ngModelCtrl = ctrls[1]; + + if (!ngModelCtrl) { + return; // do nothing if no ng-model + } + + paginationCtrl.init(ngModelCtrl, uibPagerConfig); + } + }; +}]); diff --git a/src/pager/test/pager.spec.js b/src/pager/test/pager.spec.js new file mode 100644 index 0000000000..5491a5f54f --- /dev/null +++ b/src/pager/test/pager.spec.js @@ -0,0 +1,281 @@ +describe('pager directive', function() { + var $compile, $rootScope, $document, $templateCache, body, element; + beforeEach(module('ui.bootstrap.pager')); + beforeEach(module('uib/template/pager/pager.html')); + beforeEach(inject(function(_$compile_, _$rootScope_, _$document_, _$templateCache_) { + $compile = _$compile_; + $rootScope = _$rootScope_; + $rootScope.total = 47; // 5 pages + $rootScope.currentPage = 3; + $document = _$document_; + $templateCache = _$templateCache_; + body = $document.find('body'); + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + function getPaginationBarSize() { + return element.find('li').length; + } + + function getPaginationEl(index) { + return element.find('li').eq(index); + } + + function clickPaginationEl(index) { + getPaginationEl(index).find('a').click(); + } + + function getPaginationLinkEl(elem, index) { + return elem.find('li').eq(index).find('a'); + } + + function updateCurrentPage(value) { + $rootScope.currentPage = value; + $rootScope.$digest(); + } + + it('has a "pager" css class', function() { + expect(element.hasClass('pager')).toBe(true); + }); + + it('contains 2 li elements', function() { + expect(getPaginationBarSize()).toBe(2); + expect(getPaginationEl(0).text()).toBe('« Previous'); + expect(getPaginationEl(-1).text()).toBe('Next »'); + }); + + it('aligns previous & next page', function() { + expect(getPaginationEl(0)).toHaveClass('previous'); + expect(getPaginationEl(0)).not.toHaveClass('next'); + + expect(getPaginationEl(-1)).not.toHaveClass('previous'); + expect(getPaginationEl(-1)).toHaveClass('next'); + }); + + it('exposes the controller on the template', function() { + $templateCache.put('uib/template/pager/pager.html', '{{pager.text}}'); + + element = $compile('')($rootScope); + $rootScope.$digest(); + + var ctrl = element.controller('uibPager'); + expect(ctrl).toBeDefined(); + + ctrl.text = 'foo'; + $rootScope.$digest(); + + expect(element.html()).toBe('foo'); + }); + + it('disables the "previous" link if current page is 1', function() { + updateCurrentPage(1); + expect(getPaginationEl(0)).toHaveClass('disabled'); + }); + + it('disables the "next" link if current page is num-pages', function() { + updateCurrentPage(5); + expect(getPaginationEl(-1)).toHaveClass('disabled'); + }); + + it('changes currentPage if the "previous" link is clicked', function() { + clickPaginationEl(0); + expect($rootScope.currentPage).toBe(2); + }); + + it('changes currentPage if the "next" link is clicked', function() { + clickPaginationEl(-1); + expect($rootScope.currentPage).toBe(4); + }); + + it('does not change the current page on "previous" click if already at first page', function() { + updateCurrentPage(1); + clickPaginationEl(0); + expect($rootScope.currentPage).toBe(1); + }); + + it('does not change the current page on "next" click if already at last page', function() { + updateCurrentPage(5); + clickPaginationEl(-1); + expect($rootScope.currentPage).toBe(5); + }); + + it('executes the `ng-change` expression when an element is clicked', function() { + $rootScope.selectPageHandler = jasmine.createSpy('selectPageHandler'); + element = $compile('')($rootScope); + $rootScope.$digest(); + + clickPaginationEl(-1); + expect($rootScope.selectPageHandler).toHaveBeenCalled(); + }); + + it('does not changes the number of pages when `total-items` changes', function() { + $rootScope.total = 73; // 8 pages + $rootScope.$digest(); + + expect(getPaginationBarSize()).toBe(2); + expect(getPaginationEl(0).text()).toBe('« Previous'); + expect(getPaginationEl(-1).text()).toBe('Next »'); + }); + + it('should blur the "next" link after it has been clicked', function() { + body.append(element); + var linkEl = getPaginationLinkEl(element, -1); + + linkEl.focus(); + expect(linkEl).toHaveFocus(); + + linkEl.click(); + expect(linkEl).not.toHaveFocus(); + + element.remove(); + }); + + it('should blur the "prev" link after it has been clicked', function() { + body.append(element); + var linkEl = getPaginationLinkEl(element, -1); + + linkEl.focus(); + expect(linkEl).toHaveFocus(); + + linkEl.click(); + expect(linkEl).not.toHaveFocus(); + + element.remove(); + }); + + it('allows custom templates', function() { + $templateCache.put('foo/bar.html', 'baz'); + + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(element.html()).toBe('baz'); + }); + + describe('`items-per-page`', function() { + beforeEach(function() { + $rootScope.perpage = 5; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('does not change the number of pages', function() { + expect(getPaginationBarSize()).toBe(2); + expect(getPaginationEl(0).text()).toBe('« Previous'); + expect(getPaginationEl(-1).text()).toBe('Next »'); + }); + + it('selects the last page when it is too big', function() { + $rootScope.perpage = 30; + $rootScope.$digest(); + + expect($rootScope.currentPage).toBe(2); + expect(getPaginationBarSize()).toBe(2); + expect(getPaginationEl(0)).not.toHaveClass('disabled'); + expect(getPaginationEl(-1)).toHaveClass('disabled'); + }); + }); + + describe('when `page` is not a number', function() { + it('handles string', function() { + updateCurrentPage('1'); + expect(getPaginationEl(0)).toHaveClass('disabled'); + + updateCurrentPage('05'); + expect(getPaginationEl(-1)).toHaveClass('disabled'); + }); + }); + + describe('`num-pages`', function() { + beforeEach(function() { + $rootScope.numpg = null; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('equals to total number of pages', function() { + expect($rootScope.numpg).toBe(5); + }); + }); + + describe('setting `pagerConfig`', function() { + var originalConfig = {}; + beforeEach(inject(function(uibPagerConfig) { + angular.extend(originalConfig, uibPagerConfig); + uibPagerConfig.previousText = 'PR'; + uibPagerConfig.nextText = 'NE'; + uibPagerConfig.align = false; + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + afterEach(inject(function(uibPagerConfig) { + // return it to the original state + angular.extend(uibPagerConfig, originalConfig); + })); + + it('should change paging text', function() { + expect(getPaginationEl(0).text()).toBe('PR'); + expect(getPaginationEl(-1).text()).toBe('NE'); + }); + + it('should not align previous & next page link', function() { + expect(getPaginationEl(0)).not.toHaveClass('previous'); + expect(getPaginationEl(-1)).not.toHaveClass('next'); + }); + }); + + describe('override configuration from attributes', function() { + beforeEach(function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('contains 2 li elements', function() { + expect(getPaginationBarSize()).toBe(2); + }); + + it('should change paging text from attributes', function() { + expect(getPaginationEl(0).text()).toBe('<'); + expect(getPaginationEl(-1).text()).toBe('>'); + }); + + it('should not align previous & next page link', function() { + expect(getPaginationEl(0)).not.toHaveClass('previous'); + expect(getPaginationEl(-1)).not.toHaveClass('next'); + }); + + it('changes "previous" & "next" text from interpolated attributes', function() { + $rootScope.previousText = '<<'; + $rootScope.nextText = '>>'; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getPaginationEl(0).text()).toBe('<<'); + expect(getPaginationEl(-1).text()).toBe('>>'); + }); + }); + + it('disables the component when ng-disabled is true', function() { + $rootScope.disable = true; + + element = $compile('')($rootScope); + $rootScope.$digest(); + updateCurrentPage(2); + + expect(getPaginationEl(0)).toHaveClass('disabled'); + expect(getPaginationEl(-1)).toHaveClass('disabled'); + + $rootScope.disable = false; + $rootScope.$digest(); + + expect(getPaginationEl(0)).not.toHaveClass('disabled'); + expect(getPaginationEl(-1)).not.toHaveClass('disabled'); + + $rootScope.disable = true; + $rootScope.$digest(); + + expect(getPaginationEl(0)).toHaveClass('disabled'); + expect(getPaginationEl(-1)).toHaveClass('disabled'); + }); +}); diff --git a/src/pagination/docs/demo.html b/src/pagination/docs/demo.html index e37e615cfa..68dab010ed 100644 --- a/src/pagination/docs/demo.html +++ b/src/pagination/docs/demo.html @@ -1,20 +1,24 @@ - + Default - - - - - - - Set current page to: 3 - The selected page no: {{currentPage}} + + + + + The selected page no: {{currentPage}} + Set current page to: 3 - Pager - + Limit the maximum visible buttons + rotate defaulted to true: + + rotate defaulted to true and force-ellipses set to true: + + rotate set to false: + + boundary-link-numbers set to true and rotate defaulted to true: + + boundary-link-numbers set to true and rotate set to false: + + Page: {{bigCurrentPage}} / {{numPages}} - - Limit the maximimum visible page-buttons - - diff --git a/src/pagination/docs/demo.js b/src/pagination/docs/demo.js index 7ae5ef6769..9791aab8ec 100644 --- a/src/pagination/docs/demo.js +++ b/src/pagination/docs/demo.js @@ -1,12 +1,16 @@ -var PaginationDemoCtrl = function ($scope) { - $scope.noOfPages = 7; +angular.module('ui.bootstrap.demo').controller('PaginationDemoCtrl', function ($scope, $log) { + $scope.totalItems = 64; $scope.currentPage = 4; - $scope.maxSize = 5; - + $scope.setPage = function (pageNo) { $scope.currentPage = pageNo; }; - $scope.bigNoOfPages = 18; + $scope.pageChanged = function() { + $log.log('Page changed to: ' + $scope.currentPage); + }; + + $scope.maxSize = 5; + $scope.bigTotalItems = 175; $scope.bigCurrentPage = 1; -}; +}); diff --git a/src/pagination/docs/readme.md b/src/pagination/docs/readme.md index 8394e5bb8d..34f2d05823 100644 --- a/src/pagination/docs/readme.md +++ b/src/pagination/docs/readme.md @@ -1,67 +1,99 @@ - A lightweight pagination directive that is focused on ... providing pagination & will take care of visualising a pagination bar and enable / disable buttons correctly! -### Pagination Settings ### - -Settings can be provided as attributes in the `` or globally configured through the `paginationConfig`. - - * `num-pages` - : - Number of total pages. - - * `current-page` - : - Current page number. - - * `max-size` - _(Defaults: null)_ : - Limit number for pagination size. - - * `rotate` - _(Defaults: true)_ : - Whether to keep current page in the middle of the visible ones. - - * `on-select-page (page)` - _(Default: null)_ : - An optional expression called when a page is selected having the page number as argument. - - * `direction-links` - _(Default: true)_ : - Whether to display Previous / Next buttons. - - * `previous-text` - _(Default: 'Previous')_ : - Text for Previous button. - - * `next-text` - _(Default: 'Next')_ : - Text for Next button. - - * `boundary-links` - _(Default: false)_ : - Whether to display First / Last buttons. - - * `first-text` - _(Default: 'First')_ : - Text for First button. - - * `last-text` - _(Default: 'Last')_ : - Text for Last button. - -### Pager Settings ### - -Settings can be provided as attributes in the `` or globally configured through the `pagerConfig`. -For `num-pages`, `current-page` and `on-select-page (page)` see pagination settings. Other settings are: - - * `align` - _(Default: true)_ : - Whether to align each link to the sides. - - * `previous-text` - _(Default: '« Previous')_ : - Text for Previous button. - - * `next-text` - _(Default: 'Next »')_ : - Text for Next button. +### uib-pagination settings + +* `boundary-links` + C + _(Default: `false`)_ - + Whether to display First / Last buttons. + +* `boundary-link-numbers` + $ + C + _(Default: `false`)_ - + Whether to always display the first and last page numbers. If `max-size` is smaller than the number of pages, then the first and last page numbers are still shown with ellipses in-between as necessary. NOTE: `max-size` refers to the center of the range. This option may add up to 2 more numbers on each side of the displayed range for the end value and what would be an ellipsis but is replaced by a number because it is sequential. + +* `direction-links` + $ + C + _(Default: `true`)_ - + Whether to display Previous / Next buttons. + +* `first-text` + C + _(Default: `First`)_ - + Text for First button. + +* `force-ellipses` + $ + C + _(Default: `false`)_ - + Also displays ellipses when `rotate` is true and `max-size` is smaller than the number of pages. + +* `items-per-page` + $ + C + + _(Default: `10`)_ - + Maximum number of items per page. A value less than one indicates all items on one page. + +* `last-text` + C + _(Default: `Last`)_ - + Text for Last button. + +* `max-size` + $ + + _(Default: `null`)_ - + Limit number for pagination size. + +* `next-text` + C + _(Default: `Next`)_ - + Text for Next button. + +* `ng-change` + $ - + This can be used to call a function whenever the page changes. + +* `ng-disabled` + $ + + _(Default: `false`)_ - + Used to disable the pagination component. + +* `ng-model` + $ + - + Current page number. First page is 1. + +* `num-pages` + $ + readonly + _(Default: `angular.noop`)_ - + An optional expression assigned the total number of pages to display. + +* `page-label` + _(Default: `angular.identity`)_ - + An optional expression to override the page label based on passing the current page indexes. Supports page number with `$page` in the template. + +* `previous-text` + C + _(Default: `Previous`)_ - + Text for Previous button. + +* `rotate` + $ + C + _(Default: `true`)_ - + Whether to keep current page in the middle of the visible ones. + +* `template-url` + _(Default: `uib/template/pagination/pagination.html`)_ - + Override the template for the component with a custom provided template + +* `total-items` + $ + - + Total number of items in all pages. diff --git a/src/pagination/index.js b/src/pagination/index.js new file mode 100644 index 0000000000..07899c79b0 --- /dev/null +++ b/src/pagination/index.js @@ -0,0 +1,10 @@ +require('../paging'); +require('../tabindex'); +require('../../template/pagination/pagination.html.js'); +require('./pagination'); + +var MODULE_NAME = 'ui.bootstrap.module.pagination'; + +angular.module(MODULE_NAME, ['ui.bootstrap.pagination', 'uib/template/pagination/pagination.html']); + +module.exports = MODULE_NAME; diff --git a/src/pagination/pagination.js b/src/pagination/pagination.js index f4becfcd2c..eaed65ca07 100644 --- a/src/pagination/pagination.js +++ b/src/pagination/pagination.js @@ -1,199 +1,153 @@ -angular.module('ui.bootstrap.pagination', []) - -.controller('PaginationController', ['$scope', '$interpolate', function ($scope, $interpolate) { - - this.currentPage = 1; +angular.module('ui.bootstrap.pagination', ['ui.bootstrap.paging', 'ui.bootstrap.tabindex']) +.controller('UibPaginationController', ['$scope', '$attrs', '$parse', 'uibPaging', 'uibPaginationConfig', function($scope, $attrs, $parse, uibPaging, uibPaginationConfig) { + var ctrl = this; + // Setup configuration parameters + var maxSize = angular.isDefined($attrs.maxSize) ? $scope.$parent.$eval($attrs.maxSize) : uibPaginationConfig.maxSize, + rotate = angular.isDefined($attrs.rotate) ? $scope.$parent.$eval($attrs.rotate) : uibPaginationConfig.rotate, + forceEllipses = angular.isDefined($attrs.forceEllipses) ? $scope.$parent.$eval($attrs.forceEllipses) : uibPaginationConfig.forceEllipses, + boundaryLinkNumbers = angular.isDefined($attrs.boundaryLinkNumbers) ? $scope.$parent.$eval($attrs.boundaryLinkNumbers) : uibPaginationConfig.boundaryLinkNumbers, + pageLabel = angular.isDefined($attrs.pageLabel) ? function(idx) { return $scope.$parent.$eval($attrs.pageLabel, {$page: idx}); } : angular.identity; + $scope.boundaryLinks = angular.isDefined($attrs.boundaryLinks) ? $scope.$parent.$eval($attrs.boundaryLinks) : uibPaginationConfig.boundaryLinks; + $scope.directionLinks = angular.isDefined($attrs.directionLinks) ? $scope.$parent.$eval($attrs.directionLinks) : uibPaginationConfig.directionLinks; + $attrs.$set('role', 'menu'); + + uibPaging.create(this, $scope, $attrs); + + if ($attrs.maxSize) { + ctrl._watchers.push($scope.$parent.$watch($parse($attrs.maxSize), function(value) { + maxSize = parseInt(value, 10); + ctrl.render(); + })); + } + + // Create page object used in template + function makePage(number, text, isActive) { + return { + number: number, + text: text, + active: isActive + }; + } + + function getPages(currentPage, totalPages) { + var pages = []; + + // Default page limits + var startPage = 1, endPage = totalPages; + var isMaxSized = angular.isDefined(maxSize) && maxSize < totalPages; + + // recompute if maxSize + if (isMaxSized) { + if (rotate) { + // Current page is displayed in the middle of the visible ones + startPage = Math.max(currentPage - Math.floor(maxSize / 2), 1); + endPage = startPage + maxSize - 1; + + // Adjust if limit is exceeded + if (endPage > totalPages) { + endPage = totalPages; + startPage = endPage - maxSize + 1; + } + } else { + // Visible pages are paginated with maxSize + startPage = (Math.ceil(currentPage / maxSize) - 1) * maxSize + 1; - this.noPrevious = function() { - return this.currentPage === 1; - }; - this.noNext = function() { - return this.currentPage === $scope.numPages; - }; + // Adjust last page if limit is exceeded + endPage = Math.min(startPage + maxSize - 1, totalPages); + } + } - this.isActive = function(page) { - return this.currentPage === page; - }; + // Add page number links + for (var number = startPage; number <= endPage; number++) { + var page = makePage(number, pageLabel(number), number === currentPage); + pages.push(page); + } - this.reset = function() { - $scope.pages = []; - this.currentPage = parseInt($scope.currentPage, 10); + // Add links to move between page sets + if (isMaxSized && maxSize > 0 && (!rotate || forceEllipses || boundaryLinkNumbers)) { + if (startPage > 1) { + if (!boundaryLinkNumbers || startPage > 3) { //need ellipsis for all options unless range is too close to beginning + var previousPageSet = makePage(startPage - 1, '...', false); + pages.unshift(previousPageSet); + } + if (boundaryLinkNumbers) { + if (startPage === 3) { //need to replace ellipsis when the buttons would be sequential + var secondPageLink = makePage(2, '2', false); + pages.unshift(secondPageLink); + } + //add the first page + var firstPageLink = makePage(1, '1', false); + pages.unshift(firstPageLink); + } + } - if ( this.currentPage > $scope.numPages ) { - $scope.selectPage($scope.numPages); + if (endPage < totalPages) { + if (!boundaryLinkNumbers || endPage < totalPages - 2) { //need ellipsis for all options unless range is too close to end + var nextPageSet = makePage(endPage + 1, '...', false); + pages.push(nextPageSet); + } + if (boundaryLinkNumbers) { + if (endPage === totalPages - 2) { //need to replace ellipsis when the buttons would be sequential + var secondToLastPageLink = makePage(totalPages - 1, totalPages - 1, false); + pages.push(secondToLastPageLink); + } + //add the last page + var lastPageLink = makePage(totalPages, totalPages, false); + pages.push(lastPageLink); + } + } } - }; - - var self = this; - $scope.selectPage = function(page) { - if ( ! self.isActive(page) && page > 0 && page <= $scope.numPages) { - $scope.currentPage = page; - $scope.onSelectPage({ page: page }); + return pages; + } + + var originalRender = this.render; + this.render = function() { + originalRender(); + if ($scope.page > 0 && $scope.page <= $scope.totalPages) { + $scope.pages = getPages($scope.page, $scope.totalPages); } }; - - this.getAttributeValue = function(attribute, defaultValue, interpolate) { - return angular.isDefined(attribute) ? (interpolate ? $interpolate(attribute)($scope.$parent) : $scope.$parent.$eval(attribute)) : defaultValue; - }; }]) -.constant('paginationConfig', { +.constant('uibPaginationConfig', { + itemsPerPage: 10, boundaryLinks: false, + boundaryLinkNumbers: false, directionLinks: true, firstText: 'First', previousText: 'Previous', nextText: 'Next', lastText: 'Last', - rotate: true + rotate: true, + forceEllipses: false }) -.directive('pagination', ['paginationConfig', function(config) { +.directive('uibPagination', ['$parse', 'uibPaginationConfig', function($parse, uibPaginationConfig) { return { - restrict: 'EA', scope: { - numPages: '=', - currentPage: '=', - maxSize: '=', - onSelectPage: '&' + totalItems: '=', + firstText: '@', + previousText: '@', + nextText: '@', + lastText: '@', + ngDisabled:'=' }, - controller: 'PaginationController', - templateUrl: 'template/pagination/pagination.html', - replace: true, - link: function(scope, element, attrs, paginationCtrl) { - - // Setup configuration parameters - var boundaryLinks = paginationCtrl.getAttributeValue(attrs.boundaryLinks, config.boundaryLinks ), - directionLinks = paginationCtrl.getAttributeValue(attrs.directionLinks, config.directionLinks ), - firstText = paginationCtrl.getAttributeValue(attrs.firstText, config.firstText, true), - previousText = paginationCtrl.getAttributeValue(attrs.previousText, config.previousText, true), - nextText = paginationCtrl.getAttributeValue(attrs.nextText, config.nextText, true), - lastText = paginationCtrl.getAttributeValue(attrs.lastText, config.lastText, true), - rotate = paginationCtrl.getAttributeValue(attrs.rotate, config.rotate); - - // Create page object used in template - function makePage(number, text, isActive, isDisabled) { - return { - number: number, - text: text, - active: isActive, - disabled: isDisabled - }; - } - - scope.$watch('numPages + currentPage + maxSize', function() { - paginationCtrl.reset(); - - // Default page limits - var startPage = 1, endPage = scope.numPages; - var isMaxSized = ( angular.isDefined(scope.maxSize) && scope.maxSize < scope.numPages ); - - // recompute if maxSize - if ( isMaxSized ) { - if ( rotate ) { - // Current page is displayed in the middle of the visible ones - startPage = Math.max(paginationCtrl.currentPage - Math.floor(scope.maxSize/2), 1); - endPage = startPage + scope.maxSize - 1; - - // Adjust if limit is exceeded - if (endPage > scope.numPages) { - endPage = scope.numPages; - startPage = endPage - scope.maxSize + 1; - } - } else { - // Visible pages are paginated with maxSize - startPage = ((Math.ceil(paginationCtrl.currentPage / scope.maxSize) - 1) * scope.maxSize) + 1; - - // Adjust last page if limit is exceeded - endPage = Math.min(startPage + scope.maxSize - 1, scope.numPages); - } - } - - // Add page number links - for (var number = startPage; number <= endPage; number++) { - var page = makePage(number, number, paginationCtrl.isActive(number), false); - scope.pages.push(page); - } - - // Add links to move between page sets - if ( isMaxSized && ! rotate ) { - if ( startPage > 1 ) { - var previousPageSet = makePage(startPage - 1, '...', false, false); - scope.pages.unshift(previousPageSet); - } - - if ( endPage < scope.numPages ) { - var nextPageSet = makePage(endPage + 1, '...', false, false); - scope.pages.push(nextPageSet); - } - } - - // Add previous & next links - if (directionLinks) { - var previousPage = makePage(paginationCtrl.currentPage - 1, previousText, false, paginationCtrl.noPrevious()); - scope.pages.unshift(previousPage); - - var nextPage = makePage(paginationCtrl.currentPage + 1, nextText, false, paginationCtrl.noNext()); - scope.pages.push(nextPage); - } - - // Add first & last links - if (boundaryLinks) { - var firstPage = makePage(1, firstText, false, paginationCtrl.noPrevious()); - scope.pages.unshift(firstPage); - - var lastPage = makePage(scope.numPages, lastText, false, paginationCtrl.noNext()); - scope.pages.push(lastPage); - } - }); - } - }; -}]) - -.constant('pagerConfig', { - previousText: '« Previous', - nextText: 'Next »', - align: true -}) - -.directive('pager', ['pagerConfig', function(config) { - return { - restrict: 'EA', - scope: { - numPages: '=', - currentPage: '=', - onSelectPage: '&' + require: ['uibPagination', '?ngModel'], + restrict: 'A', + controller: 'UibPaginationController', + controllerAs: 'pagination', + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/pagination/pagination.html'; }, - controller: 'PaginationController', - templateUrl: 'template/pagination/pager.html', - replace: true, - link: function(scope, element, attrs, paginationCtrl) { - - // Setup configuration parameters - var previousText = paginationCtrl.getAttributeValue(attrs.previousText, config.previousText, true), - nextText = paginationCtrl.getAttributeValue(attrs.nextText, config.nextText, true), - align = paginationCtrl.getAttributeValue(attrs.align, config.align); - - // Create page object used in template - function makePage(number, text, isDisabled, isPrevious, isNext) { - return { - number: number, - text: text, - disabled: isDisabled, - previous: ( align && isPrevious ), - next: ( align && isNext ) - }; - } - - scope.$watch('numPages + currentPage', function() { - paginationCtrl.reset(); + link: function(scope, element, attrs, ctrls) { + element.addClass('pagination'); + var paginationCtrl = ctrls[0], ngModelCtrl = ctrls[1]; - // Add previous & next links - var previousPage = makePage(paginationCtrl.currentPage - 1, previousText, paginationCtrl.noPrevious(), true, false); - scope.pages.unshift(previousPage); + if (!ngModelCtrl) { + return; // do nothing if no ng-model + } - var nextPage = makePage(paginationCtrl.currentPage + 1, nextText, paginationCtrl.noNext(), false, true); - scope.pages.push(nextPage); - }); + paginationCtrl.init(ngModelCtrl, uibPaginationConfig); } }; }]); diff --git a/src/pagination/test/pager.spec.js b/src/pagination/test/pager.spec.js deleted file mode 100644 index 91874d6e8c..0000000000 --- a/src/pagination/test/pager.spec.js +++ /dev/null @@ -1,193 +0,0 @@ -describe('pager directive with default configuration', function () { - var $rootScope, element; - beforeEach(module('ui.bootstrap.pagination')); - beforeEach(module('template/pagination/pager.html')); - beforeEach(inject(function(_$compile_, _$rootScope_) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $rootScope.numPages = 5; - $rootScope.currentPage = 3; - element = $compile('')($rootScope); - $rootScope.$digest(); - })); - - it('has a "pager" css class', function() { - expect(element.hasClass('pager')).toBe(true); - }); - - it('contains 2 li elements', function() { - expect(element.find('li').length).toBe(2); - expect(element.find('li').eq(0).text()).toBe('« Previous'); - expect(element.find('li').eq(-1).text()).toBe('Next »'); - }); - - it('aligns previous & next page', function() { - expect(element.find('li').eq(0).hasClass('previous')).toBe(true); - expect(element.find('li').eq(0).hasClass('next')).toBe(false); - - expect(element.find('li').eq(-1).hasClass('previous')).toBe(false); - expect(element.find('li').eq(-1).hasClass('next')).toBe(true); - }); - - it('disables the "previous" link if current-page is 1', function() { - $rootScope.currentPage = 1; - $rootScope.$digest(); - expect(element.find('li').eq(0).hasClass('disabled')).toBe(true); - }); - - it('disables the "next" link if current-page is num-pages', function() { - $rootScope.currentPage = 5; - $rootScope.$digest(); - expect(element.find('li').eq(-1).hasClass('disabled')).toBe(true); - }); - - it('changes currentPage if the "previous" link is clicked', function() { - var previous = element.find('li').eq(0).find('a').eq(0); - previous.click(); - $rootScope.$digest(); - expect($rootScope.currentPage).toBe(2); - }); - - it('changes currentPage if the "next" link is clicked', function() { - var next = element.find('li').eq(-1).find('a').eq(0); - next.click(); - $rootScope.$digest(); - expect($rootScope.currentPage).toBe(4); - }); - - it('does not change the current page on "previous" click if already at first page', function() { - var previous = element.find('li').eq(0).find('a').eq(0); - $rootScope.currentPage = 1; - $rootScope.$digest(); - previous.click(); - $rootScope.$digest(); - expect($rootScope.currentPage).toBe(1); - }); - - it('does not change the current page on "next" click if already at last page', function() { - var next = element.find('li').eq(-1).find('a').eq(0); - $rootScope.currentPage = 5; - $rootScope.$digest(); - next.click(); - $rootScope.$digest(); - expect($rootScope.currentPage).toBe(5); - }); - - it('executes the onSelectPage expression when the current page changes', function() { - $rootScope.selectPageHandler = jasmine.createSpy('selectPageHandler'); - element = $compile('')($rootScope); - $rootScope.$digest(); - var next = element.find('li').eq(-1).find('a').eq(0); - next.click(); - $rootScope.$digest(); - expect($rootScope.selectPageHandler).toHaveBeenCalledWith(4); - }); - - it('does not changes the number of items when numPages changes', function() { - $rootScope.numPages = 8; - $rootScope.$digest(); - expect(element.find('li').length).toBe(2); - expect(element.find('li').eq(0).text()).toBe('« Previous'); - expect(element.find('li').eq(-1).text()).toBe('Next »'); - }); - - it('sets the current page to the last page if the numPages is changed to less than the current page', function() { - $rootScope.selectPageHandler = jasmine.createSpy('selectPageHandler'); - element = $compile('')($rootScope); - $rootScope.$digest(); - $rootScope.numPages = 2; - $rootScope.$digest(); - expect(element.find('li').length).toBe(2); - expect($rootScope.currentPage).toBe(2); - expect($rootScope.selectPageHandler).toHaveBeenCalledWith(2); - }); - - describe('when `current-page` is not a number', function () { - it('handles string', function() { - $rootScope.currentPage = '1'; - $rootScope.$digest(); - expect(element.find('li').eq(0).hasClass('disabled')).toBe(true); - - $rootScope.currentPage = '05'; - $rootScope.$digest(); - expect(element.find('li').eq(-1).hasClass('disabled')).toBe(true); - }); - }); -}); - -describe('setting pagerConfig', function() { - var $rootScope, element; - var originalConfig = {}; - beforeEach(module('ui.bootstrap.pagination')); - beforeEach(module('template/pagination/pager.html')); - beforeEach(inject(function(_$compile_, _$rootScope_, pagerConfig) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $rootScope.numPages = 5; - $rootScope.currentPage = 3; - angular.extend(originalConfig, pagerConfig); - pagerConfig.previousText = 'PR'; - pagerConfig.nextText = 'NE'; - pagerConfig.align = false; - element = $compile('')($rootScope); - $rootScope.$digest(); - })); - afterEach(inject(function(pagerConfig) { - // return it to the original state - angular.extend(pagerConfig, originalConfig); - })); - - it('contains 2 li elements', function() { - expect(element.find('li').length).toBe(2); - }); - - it('should change paging text', function () { - expect(element.find('li').eq(0).text()).toBe('PR'); - expect(element.find('li').eq(-1).text()).toBe('NE'); - }); - - it('should not align previous & next page link', function () { - expect(element.find('li').eq(0).hasClass('previous')).toBe(false); - expect(element.find('li').eq(-1).hasClass('next')).toBe(false); - }); - -}); - -describe('pager bypass configuration from attributes', function () { - var $rootScope, element; - beforeEach(module('ui.bootstrap.pagination')); - beforeEach(module('template/pagination/pager.html')); - beforeEach(inject(function(_$compile_, _$rootScope_) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $rootScope.numPages = 5; - $rootScope.currentPage = 3; - element = $compile('')($rootScope); - $rootScope.$digest(); - })); - - it('contains 2 li elements', function() { - expect(element.find('li').length).toBe(2); - }); - - it('should change paging text from attributes', function () { - expect(element.find('li').eq(0).text()).toBe('<'); - expect(element.find('li').eq(-1).text()).toBe('>'); - }); - - it('should not align previous & next page link', function () { - expect(element.find('li').eq(0).hasClass('previous')).toBe(false); - expect(element.find('li').eq(-1).hasClass('next')).toBe(false); - }); - - it('changes "previous" & "next" text from interpolated attributes', function() { - $rootScope.previousText = '<<'; - $rootScope.nextText = '>>'; - element = $compile('')($rootScope); - $rootScope.$digest(); - - expect(element.find('li').eq(0).text()).toBe('<<'); - expect(element.find('li').eq(-1).text()).toBe('>>'); - }); - -}); \ No newline at end of file diff --git a/src/pagination/test/pagination.spec.js b/src/pagination/test/pagination.spec.js index 764cf2f5ac..bc4f6eee6e 100644 --- a/src/pagination/test/pagination.spec.js +++ b/src/pagination/test/pagination.spec.js @@ -1,613 +1,963 @@ -describe('pagination directive with default configuration', function () { - var $rootScope, element; +describe('pagination directive', function() { + var $compile, $rootScope, $document, $templateCache, body, element; beforeEach(module('ui.bootstrap.pagination')); - beforeEach(module('template/pagination/pagination.html')); - beforeEach(inject(function(_$compile_, _$rootScope_) { + beforeEach(module('uib/template/pagination/pagination.html')); + beforeEach(inject(function(_$compile_, _$rootScope_, _$document_, _$templateCache_) { $compile = _$compile_; $rootScope = _$rootScope_; - $rootScope.numPages = 5; + $rootScope.total = 47; // 5 pages $rootScope.currentPage = 3; - element = $compile('')($rootScope); + $rootScope.disabled = false; + $document = _$document_; + $templateCache = _$templateCache_; + body = $document.find('body'); + element = $compile('')($rootScope); $rootScope.$digest(); })); + function getPaginationBarSize() { + return element.find('li').length; + } + + function getPaginationEl(index) { + return element.find('li').eq(index); + } + + // Returns a comma-separated string that represents the pager, like: "Prev, 1, 2, 3, Next" + function getPaginationAsText() { + var len = getPaginationBarSize(), outItems = []; + for (var i = 0; i < len; i++) { + outItems.push(getPaginationEl(i).text()); + } + return outItems.join(', '); + } + + function clickPaginationEl(index) { + getPaginationEl(index).find('a').click(); + } + + function getPaginationLinkEl(elem, index) { + return elem.find('li').eq(index).find('a'); + } + + function updateCurrentPage(value) { + $rootScope.currentPage = value; + $rootScope.$digest(); + } + + function setDisabled(value) { + $rootScope.disabled = value; + $rootScope.$digest(); + } + it('has a "pagination" css class', function() { expect(element.hasClass('pagination')).toBe(true); }); - it('contains one ul and num-pages + 2 li elements', function() { - expect(element.find('ul').length).toBe(1); - expect(element.find('li').length).toBe(7); - expect(element.find('li').eq(0).text()).toBe('Previous'); - expect(element.find('li').eq(-1).text()).toBe('Next'); + it('has accessibility attributes', function() { + expect(element.attr('role')).toEqual('menu'); + + var li = element.find('li'); + for (var i = 0; i < li.length; i++) { + expect(li.eq(i).attr('role')).toEqual('menuitem'); + } + }); + + it('exposes the controller to the template', function() { + $templateCache.put('uib/template/pagination/pagination.html', '{{pagination.randomText}}'); + var scope = $rootScope.$new(); + + element = $compile('')(scope); + $rootScope.$digest(); + + var ctrl = element.controller('uibPagination'); + + expect(ctrl).toBeDefined(); + + ctrl.randomText = 'foo'; + $rootScope.$digest(); + + expect(element.html()).toBe('foo'); + }); + + it('allows custom templates', function() { + $templateCache.put('foo/bar.html', 'baz'); + var scope = $rootScope.$new(); + + element = $compile('')(scope); + $rootScope.$digest(); + + expect(element.html()).toBe('baz'); + }); + + it('contains num-pages + 2 li elements', function() { + expect(getPaginationBarSize()).toBe(7); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(-1).text()).toBe('Next'); }); it('has the number of the page as text in each page item', function() { - var lis = element.find('li'); - for(var i=1; i<=$rootScope.numPages;i++) { - expect(lis.eq(i).text()).toEqual(''+i); + for (var i = 1; i <= 5; i++) { + expect(getPaginationEl(i).text()).toEqual(''+i); } }); - it('sets the current-page to be active', function() { - var currentPageItem = element.find('li').eq($rootScope.currentPage); - expect(currentPageItem.hasClass('active')).toBe(true); + it('sets the current page to be active', function() { + expect(getPaginationEl($rootScope.currentPage).hasClass('active')).toBe(true); }); - it('disables the "previous" link if current-page is 1', function() { - $rootScope.currentPage = 1; - $rootScope.$digest(); - var previousPageItem = element.find('li').eq(0); - expect(previousPageItem.hasClass('disabled')).toBe(true); + it('disables the "previous" link if current page is 1', function() { + updateCurrentPage(1); + expect(getPaginationEl(0).hasClass('disabled')).toBe(true); }); - it('disables the "next" link if current-page is num-pages', function() { - $rootScope.currentPage = 5; - $rootScope.$digest(); - var nextPageItem = element.find('li').eq(-1); - expect(nextPageItem.hasClass('disabled')).toBe(true); + it('disables the "next" link if current page is last', function() { + updateCurrentPage(5); + expect(getPaginationEl(-1).hasClass('disabled')).toBe(true); }); it('changes currentPage if a page link is clicked', function() { - var page2 = element.find('li').eq(2).find('a'); - page2.click(); - $rootScope.$digest(); + clickPaginationEl(2); expect($rootScope.currentPage).toBe(2); }); it('changes currentPage if the "previous" link is clicked', function() { - var previous = element.find('li').eq(0).find('a').eq(0); - previous.click(); - $rootScope.$digest(); + clickPaginationEl(0); expect($rootScope.currentPage).toBe(2); }); it('changes currentPage if the "next" link is clicked', function() { - var next = element.find('li').eq(-1).find('a').eq(0); - next.click(); - $rootScope.$digest(); + clickPaginationEl(-1); expect($rootScope.currentPage).toBe(4); }); it('does not change the current page on "previous" click if already at first page', function() { - var previous = element.find('li').eq(0).find('a').eq(0); - $rootScope.currentPage = 1; - $rootScope.$digest(); - previous.click(); - $rootScope.$digest(); + updateCurrentPage(1); + clickPaginationEl(0); expect($rootScope.currentPage).toBe(1); }); it('does not change the current page on "next" click if already at last page', function() { - var next = element.find('li').eq(-1).find('a').eq(0); - $rootScope.currentPage = 5; - $rootScope.$digest(); - next.click(); - $rootScope.$digest(); + updateCurrentPage(5); + clickPaginationEl(-1); expect($rootScope.currentPage).toBe(5); }); - it('executes the onSelectPage expression when the current page changes', function() { - $rootScope.selectPageHandler = jasmine.createSpy('selectPageHandler'); - element = $compile('')($rootScope); - $rootScope.$digest(); - var page2 = element.find('li').eq(2).find('a').eq(0); - page2.click(); + it('changes the number of pages when `total-items` changes', function() { + $rootScope.total = 78; // 8 pages $rootScope.$digest(); - expect($rootScope.selectPageHandler).toHaveBeenCalledWith(2); - }); - it('changes the number of items when numPages changes', function() { - $rootScope.numPages = 8; - $rootScope.$digest(); - expect(element.find('li').length).toBe(10); - expect(element.find('li').eq(0).text()).toBe('Previous'); - expect(element.find('li').eq(-1).text()).toBe('Next'); + expect(getPaginationBarSize()).toBe(10); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(-1).text()).toBe('Next'); }); - it('sets the current page to the last page if the numPages is changed to less than the current page', function() { - $rootScope.selectPageHandler = jasmine.createSpy('selectPageHandler'); - element = $compile('')($rootScope); - $rootScope.$digest(); - $rootScope.numPages = 2; + it('does not "break" when `total-items` is undefined', function() { + $rootScope.total = undefined; $rootScope.$digest(); - expect(element.find('li').length).toBe(4); - expect($rootScope.currentPage).toBe(2); - expect($rootScope.selectPageHandler).toHaveBeenCalledWith(2); - }); - - describe('when `current-page` is not a number', function () { - it('handles string', function() { - $rootScope.currentPage = '2'; - $rootScope.$digest(); - expect(element.find('li').eq(2).hasClass('active')).toBe(true); - $rootScope.currentPage = '04'; - $rootScope.$digest(); - expect(element.find('li').eq(4).hasClass('active')).toBe(true); - }); + expect(getPaginationBarSize()).toBe(3); // Previous, 1, Next + expect(getPaginationEl(0)).toHaveClass('disabled'); + expect(getPaginationEl(1)).toHaveClass('active'); + expect(getPaginationEl(2)).toHaveClass('disabled'); }); -}); -describe('pagination directive with max size option', function () { - var $rootScope, element; - beforeEach(module('ui.bootstrap.pagination')); - beforeEach(module('template/pagination/pagination.html')); - beforeEach(inject(function(_$compile_, _$rootScope_) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $rootScope.numPages = 10; - $rootScope.currentPage = 3; - $rootScope.maxSize = 5; - element = $compile('')($rootScope); + it('does not "break" when `total-items` is negative', function() { + $rootScope.total = -1; $rootScope.$digest(); - })); - - it('contains one ul and maxsize + 2 li elements', function() { - expect(element.find('ul').length).toBe(1); - expect(element.find('li').length).toBe($rootScope.maxSize + 2); - expect(element.find('li').eq(0).text()).toBe('Previous'); - expect(element.find('li').eq(-1).text()).toBe('Next'); + + expect(getPaginationBarSize()).toBe(3); // Previous, 1, Next + expect(getPaginationEl(0)).toHaveClass('disabled'); + expect(getPaginationEl(1)).toHaveClass('active'); + expect(getPaginationEl(2)).toHaveClass('disabled'); }); - it('shows the page number even if it can\'t be shown in the middle', function() { + it('does not change the current page when `total-items` changes but is valid', function() { $rootScope.currentPage = 1; + $rootScope.total = 18; // 2 pages $rootScope.$digest(); - var currentPageItem = element.find('li').eq(1); - expect(currentPageItem.hasClass('active')).toBe(true); - - $rootScope.currentPage = 10; - $rootScope.$digest(); - currentPageItem = element.find('li').eq(-2); - expect(currentPageItem.hasClass('active')).toBe(true); - }); - - it('shows the page number in middle after the next link is clicked', function() { - $rootScope.currentPage = 6; - $rootScope.$digest(); - var next = element.find('li').eq(-1).find('a').eq(0); - next.click(); - expect($rootScope.currentPage).toBe(7); - var currentPageItem = element.find('li').eq(3); - expect(currentPageItem.hasClass('active')).toBe(true); - expect(parseInt(currentPageItem.text(), 10)).toBe($rootScope.currentPage); - }); - - it('shows the page number in middle after the prev link is clicked', function() { - $rootScope.currentPage = 7; - $rootScope.$digest(); - var prev = element.find('li').eq(0).find('a').eq(0); - prev.click(); - expect($rootScope.currentPage).toBe(6); - var currentPageItem = element.find('li').eq(3); - expect(currentPageItem.hasClass('active')).toBe(true); - expect(parseInt(currentPageItem.text(), 10)).toBe($rootScope.currentPage); - }); - - it('changes pagination bar size when max-size value changed', function() { - $rootScope.maxSize = 7; - $rootScope.$digest(); - expect(element.find('li').length).toBe(9); - }); - it('sets the pagination bar size to num-pages, if max-size is greater than num-pages ', function() { - $rootScope.maxSize = 15; - $rootScope.$digest(); - expect(element.find('li').length).toBe(12); + expect($rootScope.currentPage).toBe(1); }); - it('should not change value of max-size expression, if max-size is greater than num-pages ', function() { - $rootScope.maxSize = 15; - $rootScope.$digest(); - expect($rootScope.maxSize).toBe(15); - }); + it('should blur a page link after it has been clicked', function() { + body.append(element); + var linkEl = getPaginationLinkEl(element, 2); - it('should not display page numbers, if max-size is zero', function() { - $rootScope.maxSize = 0; - $rootScope.$digest(); - expect(element.find('li').length).toBe(2); - expect(element.find('li').eq(0).text()).toBe('Previous'); - expect(element.find('li').eq(-1).text()).toBe('Next'); + linkEl.focus(); + expect(linkEl).toHaveFocus(); + + linkEl.click(); + expect(linkEl).not.toHaveFocus(); + + element.remove(); }); -}); + it('should blur the "next" link after it has been clicked', function() { + body.append(element); + var linkEl = getPaginationLinkEl(element, -1); -describe('pagination directive with max size option & no rotate', function () { - var $rootScope, element; - beforeEach(module('ui.bootstrap.pagination')); - beforeEach(module('template/pagination/pagination.html')); - beforeEach(inject(function(_$compile_, _$rootScope_) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $rootScope.numPages = 12; - $rootScope.currentPage = 7; - $rootScope.maxSize = 5; - $rootScope.rotate = false; - element = $compile('')($rootScope); - $rootScope.$digest(); - })); + linkEl.focus(); + expect(linkEl).toHaveFocus(); - it('contains one ul and maxsize + 4 elements', function() { - expect(element.find('ul').length).toBe(1); - expect(element.find('li').length).toBe($rootScope.maxSize + 4); - expect(element.find('li').eq(0).text()).toBe('Previous'); - expect(element.find('li').eq(1).text()).toBe('...'); - expect(element.find('li').eq(2).text()).toBe('6'); - expect(element.find('li').eq(-3).text()).toBe('10'); - expect(element.find('li').eq(-2).text()).toBe('...'); - expect(element.find('li').eq(-1).text()).toBe('Next'); - }); + linkEl.click(); + expect(linkEl).not.toHaveFocus(); - it('shows only the next ellipsis element on first page set', function() { - $rootScope.currentPage = 3; - $rootScope.$digest(); - expect(element.find('li').eq(1).text()).toBe('1'); - expect(element.find('li').eq(-3).text()).toBe('5'); - expect(element.find('li').eq(-2).text()).toBe('...'); + element.remove(); }); - it('shows only the previous ellipsis element on last page set', function() { - $rootScope.currentPage = 12; - $rootScope.$digest(); - expect(element.find('li').length).toBe(5); - expect(element.find('li').eq(1).text()).toBe('...'); - expect(element.find('li').eq(2).text()).toBe('11'); - expect(element.find('li').eq(-2).text()).toBe('12'); - }); + it('should blur the "prev" link after it has been clicked', function() { + body.append(element); + var linkEl = getPaginationLinkEl(element, 0); - it('moves to the previous set when first ellipsis is clicked', function() { - var prev = element.find('li').eq(1).find('a').eq(0); - expect(prev.text()).toBe('...'); + linkEl.focus(); + expect(linkEl).toHaveFocus(); - prev.click(); - expect($rootScope.currentPage).toBe(5); - var currentPageItem = element.find('li').eq(-3); - expect(currentPageItem.hasClass('active')).toBe(true); + linkEl.click(); + expect(linkEl).not.toHaveFocus(); + + element.remove(); }); - it('moves to the next set when last ellipsis is clicked', function() { - var next = element.find('li').eq(-2).find('a').eq(0); - expect(next.text()).toBe('...'); + describe('`items-per-page`', function() { + beforeEach(function() { + $rootScope.perpage = 5; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); - next.click(); - expect($rootScope.currentPage).toBe(11); - var currentPageItem = element.find('li').eq(2); - expect(currentPageItem.hasClass('active')).toBe(true); - }); + it('changes the number of pages', function() { + expect(getPaginationBarSize()).toBe(12); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(-1).text()).toBe('Next'); + }); - it('should not display page numbers, if max-size is zero', function() { - $rootScope.maxSize = 0; - $rootScope.$digest(); - expect(element.find('li').length).toBe(2); - expect(element.find('li').eq(0).text()).toBe('Previous'); - expect(element.find('li').eq(-1).text()).toBe('Next'); - }); -}); + it('changes the number of pages when changes', function() { + $rootScope.perpage = 20; + $rootScope.$digest(); -describe('pagination directive with added first & last links', function () { - var $rootScope, element; - beforeEach(module('ui.bootstrap.pagination')); - beforeEach(module('template/pagination/pagination.html')); - beforeEach(inject(function(_$compile_, _$rootScope_) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $rootScope.numPages = 5; - $rootScope.currentPage = 3; - element = $compile('')($rootScope); - $rootScope.$digest(); - })); + expect(getPaginationBarSize()).toBe(5); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(-1).text()).toBe('Next'); + }); - it('contains one ul and num-pages + 4 li elements', function() { - expect(element.find('ul').length).toBe(1); - expect(element.find('li').length).toBe(9); - expect(element.find('li').eq(0).text()).toBe('First'); - expect(element.find('li').eq(1).text()).toBe('Previous'); - expect(element.find('li').eq(-2).text()).toBe('Next'); - expect(element.find('li').eq(-1).text()).toBe('Last'); - }); + it('selects the last page when current page is too big', function() { + $rootScope.perpage = 30; + $rootScope.$digest(); + + expect($rootScope.currentPage).toBe(2); + expect(getPaginationBarSize()).toBe(4); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(-1).text()).toBe('Next'); + }); - it('has first and last li visible & with borders', function() { - var firstLiEl = element.find('li').eq(0); - var lastLiEl = element.find('li').eq(-1); + it('displays a single page when it is negative', function() { + $rootScope.perpage = -1; + $rootScope.$digest(); - expect(firstLiEl.text()).toBe('First'); - expect(firstLiEl.css('display')).not.toBe('none'); - expect(lastLiEl.text()).toBe('Last'); - expect(lastLiEl.css('display')).not.toBe('none'); + expect(getPaginationBarSize()).toBe(3); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(1).text()).toBe('1'); + expect(getPaginationEl(-1).text()).toBe('Next'); + }); }); + describe('executes `ng-change` expression', function() { + beforeEach(function() { + $rootScope.selectPageHandler = jasmine.createSpy('selectPageHandler'); + element = $compile('')($rootScope); + $rootScope.$digest(); + }); - it('disables the "first" & "previous" link if current-page is 1', function() { - $rootScope.currentPage = 1; - $rootScope.$digest(); - expect(element.find('li').eq(0).hasClass('disabled')).toBe(true); - expect(element.find('li').eq(1).hasClass('disabled')).toBe(true); + it('when an element is clicked', function() { + clickPaginationEl(2); + expect($rootScope.selectPageHandler).toHaveBeenCalled(); + }); }); - it('disables the "last" & "next" link if current-page is num-pages', function() { - $rootScope.currentPage = 5; - $rootScope.$digest(); - expect(element.find('li').eq(-2).hasClass('disabled')).toBe(true); - expect(element.find('li').eq(-1).hasClass('disabled')).toBe(true); - }); + describe('when `page` is not a number', function() { + it('handles numerical string', function() { + updateCurrentPage('2'); + expect(getPaginationEl(2)).toHaveClass('active'); - it('changes currentPage if the "first" link is clicked', function() { - var first = element.find('li').eq(0).find('a').eq(0); - first.click(); - $rootScope.$digest(); - expect($rootScope.currentPage).toBe(1); - }); + updateCurrentPage('04'); + expect(getPaginationEl(4)).toHaveClass('active'); + }); - it('changes currentPage if the "last" link is clicked', function() { - var last = element.find('li').eq(-1).find('a').eq(0); - last.click(); - $rootScope.$digest(); - expect($rootScope.currentPage).toBe($rootScope.numPages); + it('defaults to 1 if non-numeric', function() { + updateCurrentPage('pizza'); + expect(getPaginationEl(1)).toHaveClass('active'); + }); }); - it('does not change the current page on "first" click if already at first page', function() { - var first = element.find('li').eq(0).find('a').eq(0); - $rootScope.currentPage = 1; - $rootScope.$digest(); - first.click(); - $rootScope.$digest(); - expect($rootScope.currentPage).toBe(1); - }); + describe('with `max-size` option', function() { + beforeEach(function() { + $rootScope.total = 98; // 10 pages + $rootScope.currentPage = 3; + $rootScope.maxSize = 5; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); - it('does not change the current page on "last" click if already at last page', function() { - var last = element.find('li').eq(-1).find('a').eq(0); - $rootScope.currentPage = $rootScope.numPages; - $rootScope.$digest(); - last.click(); - $rootScope.$digest(); - expect($rootScope.currentPage).toBe($rootScope.numPages); - }); + it('contains maxsize + 2 li elements', function() { + expect(getPaginationBarSize()).toBe($rootScope.maxSize + 2); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(-1).text()).toBe('Next'); + }); - it('changes "first" & "last" text from attributes', function() { - element = $compile('')($rootScope); - $rootScope.$digest(); + it('shows the page number even if it can\'t be shown in the middle', function() { + updateCurrentPage(1); + expect(getPaginationEl(1)).toHaveClass('active'); - expect(element.find('li').eq(0).text()).toBe('<<<'); - expect(element.find('li').eq(-1).text()).toBe('>>>'); - }); + updateCurrentPage(10); + expect(getPaginationEl(-2)).toHaveClass('active'); + }); - it('changes "previous" & "next" text from attributes', function() { - element = $compile('')($rootScope); - $rootScope.$digest(); + it('shows the page number in middle after the next link is clicked', function() { + updateCurrentPage(6); + clickPaginationEl(-1); - expect(element.find('li').eq(1).text()).toBe('<<'); - expect(element.find('li').eq(-2).text()).toBe('>>'); - }); + expect($rootScope.currentPage).toBe(7); + expect(getPaginationEl(3)).toHaveClass('active'); + expect(getPaginationEl(3).text()).toBe(''+$rootScope.currentPage); + }); - it('changes "first" & "last" text from interpolated attributes', function() { - $rootScope.myfirstText = '<<<'; - $rootScope.mylastText = '>>>'; - element = $compile('')($rootScope); - $rootScope.$digest(); + it('shows the page number in middle after the prev link is clicked', function() { + updateCurrentPage(7); + clickPaginationEl(0); - expect(element.find('li').eq(0).text()).toBe('<<<'); - expect(element.find('li').eq(-1).text()).toBe('>>>'); - }); + expect($rootScope.currentPage).toBe(6); + expect(getPaginationEl(3)).toHaveClass('active'); + expect(getPaginationEl(3).text()).toBe(''+$rootScope.currentPage); + }); - it('changes "previous" & "next" text from interpolated attributes', function() { - $rootScope.previousText = '<<'; - $rootScope.nextText = '>>'; - element = $compile('')($rootScope); - $rootScope.$digest(); + it('changes pagination bar size when max-size value changed', function() { + $rootScope.maxSize = 7; + $rootScope.$digest(); + expect(getPaginationBarSize()).toBe(9); + }); - expect(element.find('li').eq(1).text()).toBe('<<'); - expect(element.find('li').eq(-2).text()).toBe('>>'); - }); + it('sets the pagination bar size to num-pages, if max-size is greater than num-pages ', function() { + $rootScope.maxSize = 15; + $rootScope.$digest(); + expect(getPaginationBarSize()).toBe(12); + }); -}); + it('should not change value of max-size expression, if max-size is greater than num-pages ', function() { + $rootScope.maxSize = 15; + $rootScope.$digest(); + expect($rootScope.maxSize).toBe(15); + }); -describe('pagination directive with just number links', function () { - var $rootScope, element; - beforeEach(module('ui.bootstrap.pagination')); - beforeEach(module('template/pagination/pagination.html')); - beforeEach(inject(function(_$compile_, _$rootScope_) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $rootScope.numPages = 5; - $rootScope.currentPage = 3; - element = $compile('')($rootScope); - $rootScope.$digest(); - })); + it('should not display page numbers, if max-size is zero', function() { + $rootScope.maxSize = 0; + $rootScope.$digest(); + expect(getPaginationBarSize()).toBe(2); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(-1).text()).toBe('Next'); + }); - it('has a "pagination" css class', function() { - expect(element.hasClass('pagination')).toBe(true); - }); + it('should blur page link when visible range changes', function () { + body.append(element); + var linkEl = getPaginationLinkEl(element, 4); - it('contains one ul and num-pages li elements', function() { - expect(element.find('ul').length).toBe(1); - expect(element.find('li').length).toBe(5); - expect(element.find('li').eq(0).text()).toBe('1'); - expect(element.find('li').eq(-1).text()).toBe(''+$rootScope.numPages); - }); + linkEl.focus(); + expect(linkEl).toHaveFocus(); - it('has the number of the page as text in each page item', function() { - var lis = element.find('li'); - for(var i=0; i<$rootScope.numPages;i++) { - expect(lis.eq(i).text()).toEqual(''+(i+1)); - } - }); + linkEl.click(); + expect(linkEl).not.toHaveFocus(); - it('sets the current-page to be active', function() { - var currentPageItem = element.find('li').eq($rootScope.currentPage-1); - expect(currentPageItem.hasClass('active')).toBe(true); + element.remove(); + }); }); - it('does not disable the "1" link if current-page is 1', function() { - $rootScope.currentPage = 1; - $rootScope.$digest(); - var onePageItem = element.find('li').eq(0); - expect(onePageItem.hasClass('disabled')).toBe(false); - expect(onePageItem.hasClass('active')).toBe(true); - }); + describe('with `force-ellipses` option', function() { + beforeEach(function() { + $rootScope.total = 98; // 10 pages + $rootScope.currentPage = 3; + $rootScope.maxSize = 5; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); - it('does not disable the "numPages" link if current-page is num-pages', function() { - $rootScope.currentPage = 5; - $rootScope.$digest(); - var lastPageItem = element.find('li').eq(-1); - expect(lastPageItem.hasClass('disabled')).toBe(false); - expect(lastPageItem.hasClass('active')).toBe(true); - }); + it('contains maxsize + 3 li elements', function() { + expect(getPaginationBarSize()).toBe($rootScope.maxSize + 3); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(-1).text()).toBe('Next'); + expect(getPaginationEl(-2).text()).toBe('...'); + }); - it('changes currentPage if a page link is clicked', function() { - var page2 = element.find('li').eq(1).find('a'); - page2.click(); - $rootScope.$digest(); - expect($rootScope.currentPage).toBe(2); - }); + it('shows the page number in middle after the next link is clicked', function() { + updateCurrentPage(6); + clickPaginationEl(-1); - it('executes the onSelectPage expression when the current page changes', function() { - $rootScope.selectPageHandler = jasmine.createSpy('selectPageHandler'); - element = $compile('')($rootScope); - $rootScope.$digest(); - var page2 = element.find('li').eq(1).find('a').eq(0); - page2.click(); - $rootScope.$digest(); - expect($rootScope.selectPageHandler).toHaveBeenCalledWith(2); - }); + expect($rootScope.currentPage).toBe(7); + expect(getPaginationEl(4)).toHaveClass('active'); + expect(getPaginationEl(4).text()).toBe(''+$rootScope.currentPage); + }); - it('changes the number of items when numPages changes', function() { - $rootScope.numPages = 8; - $rootScope.$digest(); - expect(element.find('li').length).toBe(8); - expect(element.find('li').eq(0).text()).toBe('1'); - expect(element.find('li').eq(-1).text()).toBe(''+$rootScope.numPages); + it('shows the page number in middle after the prev link is clicked', function() { + updateCurrentPage(7); + clickPaginationEl(0); + + expect($rootScope.currentPage).toBe(6); + expect(getPaginationEl(4)).toHaveClass('active'); + expect(getPaginationEl(4).text()).toBe(''+$rootScope.currentPage); + }); + + it('changes pagination bar size when max-size value changed', function() { + $rootScope.maxSize = 7; + $rootScope.$digest(); + expect(getPaginationBarSize()).toBe(10); + }); + + it('should display an ellipsis on the right if the last displayed page\'s number is less than the last page', function() { + updateCurrentPage(1); + expect(getPaginationAsText()).toBe('Previous, 1, 2, 3, 4, 5, ..., Next'); + }); + + it('should display an ellipsis on the left if the first displayed page\'s number is greater than 1', function() { + updateCurrentPage(10); + expect(getPaginationAsText()).toBe('Previous, ..., 6, 7, 8, 9, 10, Next'); + }); + + it('should display both ellipsis\' if the displayed range is in the middle', function() { + updateCurrentPage(5); + expect(getPaginationAsText()).toBe('Previous, ..., 3, 4, 5, 6, 7, ..., Next'); + }); + + it('should not display any ellipses if the number of pages >= maxsize', function() { + $rootScope.maxSize = 10; + $rootScope.$digest(); + expect(getPaginationAsText()).toBe('Previous, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, Next'); + }); }); - it('sets the current page to the last page if the numPages is changed to less than the current page', function() { - $rootScope.selectPageHandler = jasmine.createSpy('selectPageHandler'); - element = $compile('')($rootScope); - $rootScope.$digest(); - $rootScope.numPages = 2; - $rootScope.$digest(); - expect(element.find('li').length).toBe(2); - expect($rootScope.currentPage).toBe(2); - expect($rootScope.selectPageHandler).toHaveBeenCalledWith(2); + describe('with `boundary-link-numbers` option', function() { + beforeEach(function() { + $rootScope.total = 98; // 10 pages + $rootScope.currentPage = 3; + $rootScope.maxSize = 5; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('contains maxsize + 4 li elements', function() { + expect(getPaginationBarSize()).toBe($rootScope.maxSize + 4); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(-1).text()).toBe('Next'); + expect(getPaginationEl(-2).text()).toBe('10'); + expect(getPaginationEl(-3).text()).toBe('...'); + }); + + it('shows the page number in middle after the next link is clicked', function() { + updateCurrentPage(6); + clickPaginationEl(-1); + + expect($rootScope.currentPage).toBe(7); + expect(getPaginationEl(5)).toHaveClass('active'); + expect(getPaginationEl(5).text()).toBe(''+$rootScope.currentPage); + }); + + it('shows the page number in middle after the prev link is clicked', function() { + updateCurrentPage(7); + clickPaginationEl(0); + + expect($rootScope.currentPage).toBe(6); + expect(getPaginationEl(5)).toHaveClass('active'); + expect(getPaginationEl(5).text()).toBe(''+$rootScope.currentPage); + }); + + it('changes pagination bar size when max-size value changed', function() { + $rootScope.maxSize = 7; + $rootScope.$digest(); + expect(getPaginationBarSize()).toBe(11); + }); + + it('should display an ellipsis on the right if the last displayed page\'s number is less than the last page', function() { + updateCurrentPage(1); + expect(getPaginationAsText()).toBe('Previous, 1, 2, 3, 4, 5, ..., 10, Next'); + }); + + it('should display an ellipsis on the left if the first displayed page\'s number is greater than 1', function() { + updateCurrentPage(10); + expect(getPaginationAsText()).toBe('Previous, 1, ..., 6, 7, 8, 9, 10, Next'); + }); + + it('should display both ellipses if the displayed range is in the middle', function() { + $rootScope.maxSize = 3; + $rootScope.$digest(); + updateCurrentPage(6); + expect(getPaginationAsText()).toBe('Previous, 1, ..., 5, 6, 7, ..., 10, Next'); + }); + + it('should not display any ellipses if the number of pages >= maxsize', function() { + $rootScope.maxSize = 10; + $rootScope.$digest(); + expect(getPaginationAsText()).toBe('Previous, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, Next'); + }); + + it('should not display an ellipsis on the left if the start page is 2', function() { + updateCurrentPage(4); + expect(getPaginationAsText()).toBe('Previous, 1, 2, 3, 4, 5, 6, ..., 10, Next'); + }); + + it('should not display an ellipsis on the left if the start page is 3', function() { + updateCurrentPage(5); + expect(getPaginationAsText()).toBe('Previous, 1, 2, 3, 4, 5, 6, 7, ..., 10, Next'); + }); + + it('should not display an ellipsis on the right if the end page is totalPages - 1', function() { + updateCurrentPage(7); + expect(getPaginationAsText()).toBe('Previous, 1, ..., 5, 6, 7, 8, 9, 10, Next'); + }); + + it('should not display an ellipsis on the right if the end page is totalPages - 2', function() { + updateCurrentPage(6); + expect(getPaginationAsText()).toBe('Previous, 1, ..., 4, 5, 6, 7, 8, 9, 10, Next'); + }); + + it('should not display any ellipses if the number of pages <= maxsize + 4 and current page is in center', function() { + $rootScope.total = 88; // 9 pages + $rootScope.$digest(); + updateCurrentPage(5); + expect(getPaginationAsText()).toBe('Previous, 1, 2, 3, 4, 5, 6, 7, 8, 9, Next'); + }); }); -}); -describe('setting paginationConfig', function() { - var $rootScope, element; - var originalConfig = {}; - beforeEach(module('ui.bootstrap.pagination')); - beforeEach(module('template/pagination/pagination.html')); - beforeEach(inject(function(_$compile_, _$rootScope_, paginationConfig) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $rootScope.numPages = 5; - $rootScope.currentPage = 3; - angular.extend(originalConfig, paginationConfig); - paginationConfig.boundaryLinks = true; - paginationConfig.directionLinks = true; - paginationConfig.firstText = 'FI'; - paginationConfig.previousText = 'PR'; - paginationConfig.nextText = 'NE'; - paginationConfig.lastText = 'LA'; - element = $compile('')($rootScope); - $rootScope.$digest(); - })); - afterEach(inject(function(paginationConfig) { - // return it to the original state - angular.extend(paginationConfig, originalConfig); - })); + describe('with `max-size` option & no `rotate`', function() { + beforeEach(function() { + $rootScope.total = 115; // 12 pages + $rootScope.currentPage = 7; + $rootScope.maxSize = 5; + $rootScope.rotate = false; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('contains maxsize + 4 elements', function() { + expect(getPaginationBarSize()).toBe($rootScope.maxSize + 4); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(1).text()).toBe('...'); + expect(getPaginationEl(2).text()).toBe('6'); + expect(getPaginationEl(-3).text()).toBe('10'); + expect(getPaginationEl(-2).text()).toBe('...'); + expect(getPaginationEl(-1).text()).toBe('Next'); + }); + + it('shows only the next ellipsis element on first page set', function() { + updateCurrentPage(3); + expect(getPaginationEl(1).text()).toBe('1'); + expect(getPaginationEl(-3).text()).toBe('5'); + expect(getPaginationEl(-2).text()).toBe('...'); + }); + + it('shows only the previous ellipsis element on last page set', function() { + updateCurrentPage(12); + expect(getPaginationBarSize()).toBe(5); + expect(getPaginationEl(1).text()).toBe('...'); + expect(getPaginationEl(2).text()).toBe('11'); + expect(getPaginationEl(-2).text()).toBe('12'); + }); + + it('moves to the previous set when first ellipsis is clicked', function() { + expect(getPaginationEl(1).text()).toBe('...'); + + clickPaginationEl(1); + + expect($rootScope.currentPage).toBe(5); + expect(getPaginationEl(-3)).toHaveClass('active'); + }); - it('should change paging text', function () { - expect(element.find('li').eq(0).text()).toBe('FI'); - expect(element.find('li').eq(1).text()).toBe('PR'); - expect(element.find('li').eq(-2).text()).toBe('NE'); - expect(element.find('li').eq(-1).text()).toBe('LA'); + it('moves to the next set when last ellipsis is clicked', function() { + expect(getPaginationEl(-2).text()).toBe('...'); + + clickPaginationEl(-2); + + expect($rootScope.currentPage).toBe(11); + expect(getPaginationEl(2)).toHaveClass('active'); + }); + + it('should not display page numbers, if max-size is zero', function() { + $rootScope.maxSize = 0; + $rootScope.$digest(); + + expect(getPaginationBarSize()).toBe(2); + expect(getPaginationEl(0).text()).toBe('Previous'); + expect(getPaginationEl(1).text()).toBe('Next'); + }); }); - it('contains one ul and num-pages + 4 li elements', function() { - expect(element.find('ul').length).toBe(1); - expect(element.find('li').length).toBe(9); + describe('pagination directive with `boundary-links`', function() { + beforeEach(function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('contains num-pages + 4 li elements', function() { + expect(getPaginationBarSize()).toBe(9); + expect(getPaginationEl(0).text()).toBe('First'); + expect(getPaginationEl(1).text()).toBe('Previous'); + expect(getPaginationEl(-2).text()).toBe('Next'); + expect(getPaginationEl(-1).text()).toBe('Last'); + }); + + it('has first and last li elements visible', function() { + expect(getPaginationEl(0).css('display')).not.toBe('none'); + expect(getPaginationEl(-1).css('display')).not.toBe('none'); + }); + + + it('disables the "first" & "previous" link if current page is 1', function() { + updateCurrentPage(1); + + expect(getPaginationEl(0)).toHaveClass('disabled'); + expect(getPaginationEl(1)).toHaveClass('disabled'); + }); + + it('disables the "last" & "next" link if current page is num-pages', function() { + updateCurrentPage(5); + + expect(getPaginationEl(-2)).toHaveClass('disabled'); + expect(getPaginationEl(-1)).toHaveClass('disabled'); + }); + + it('changes currentPage if the "first" link is clicked', function() { + clickPaginationEl(0); + expect($rootScope.currentPage).toBe(1); + }); + + it('changes currentPage if the "last" link is clicked', function() { + clickPaginationEl(-1); + expect($rootScope.currentPage).toBe(5); + }); + + it('does not change the current page on "first" click if already at first page', function() { + updateCurrentPage(1); + clickPaginationEl(0); + expect($rootScope.currentPage).toBe(1); + }); + + it('does not change the current page on "last" click if already at last page', function() { + updateCurrentPage(5); + clickPaginationEl(-1); + expect($rootScope.currentPage).toBe(5); + }); + + it('changes "first" & "last" text from attributes', function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getPaginationEl(0).text()).toBe('<<<'); + expect(getPaginationEl(-1).text()).toBe('>>>'); + }); + + it('changes "previous" & "next" text from attributes', function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getPaginationEl(1).text()).toBe('<<'); + expect(getPaginationEl(-2).text()).toBe('>>'); + }); + + it('changes "first" & "last" text from interpolated attributes', function() { + $rootScope.myfirstText = '<<<'; + $rootScope.mylastText = '>>>'; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getPaginationEl(0).text()).toBe('<<<'); + expect(getPaginationEl(-1).text()).toBe('>>>'); + }); + + it('changes "previous" & "next" text from interpolated attributes', function() { + $rootScope.previousText = '<<'; + $rootScope.nextText = '>>'; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getPaginationEl(1).text()).toBe('<<'); + expect(getPaginationEl(-2).text()).toBe('>>'); + }); + + it('should blur the "first" link after it has been clicked', function() { + body.append(element); + var linkEl = getPaginationLinkEl(element, 0); + + linkEl.focus(); + expect(linkEl).toHaveFocus(); + + linkEl.click(); + expect(linkEl).not.toHaveFocus(); + + element.remove(); + }); + + it('should blur the "last" link after it has been clicked', function() { + body.append(element); + var linkEl = getPaginationLinkEl(element, -1); + + linkEl.focus(); + expect(linkEl).toHaveFocus(); + + linkEl.click(); + expect(linkEl).not.toHaveFocus(); + + element.remove(); + }); }); -}); + describe('pagination directive with just number links', function() { + beforeEach(function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + it('contains num-pages li elements', function() { + expect(getPaginationBarSize()).toBe(5); + expect(getPaginationEl(0).text()).toBe('1'); + expect(getPaginationEl(-1).text()).toBe('5'); + }); -describe('pagination directive with first, last & number links', function () { - var $rootScope, element; - beforeEach(module('ui.bootstrap.pagination')); - beforeEach(module('template/pagination/pagination.html')); - beforeEach(inject(function(_$compile_, _$rootScope_) { - $compile = _$compile_; - $rootScope = _$rootScope_; - $rootScope.numPages = 5; - $rootScope.currentPage = 3; - $rootScope.directions = false; - element = $compile('')($rootScope); - $rootScope.$digest(); - })); + it('has the number of the page as text in each page item', function() { + for(var i = 0; i < 5; i++) { + expect(getPaginationEl(i).text()).toEqual(''+(i+1)); + } + }); + + it('sets the current page to be active', function() { + expect(getPaginationEl(2)).toHaveClass('active'); + }); + + it('does not disable the "1" link if current page is 1', function() { + updateCurrentPage(1); + + expect(getPaginationEl(0)).not.toHaveClass('disabled'); + expect(getPaginationEl(0)).toHaveClass('active'); + }); - it('contains one ul and num-pages + 2 li elements', function() { - expect(element.find('ul').length).toBe(1); - expect(element.find('li').length).toBe(7); - expect(element.find('li').eq(0).text()).toBe('First'); - expect(element.find('li').eq(1).text()).toBe('1'); - expect(element.find('li').eq(-2).text()).toBe(''+$rootScope.numPages); - expect(element.find('li').eq(-1).text()).toBe('Last'); + it('does not disable the "last" link if current page is last page', function() { + updateCurrentPage(5); + + expect(getPaginationEl(-1)).not.toHaveClass('disabled'); + expect(getPaginationEl(-1)).toHaveClass('active'); + }); + + it('changes currentPage if a page link is clicked', function() { + clickPaginationEl(1); + expect($rootScope.currentPage).toBe(2); + }); + + it('changes the number of items when total items changes', function() { + $rootScope.total = 73; // 8 pages + $rootScope.$digest(); + + expect(getPaginationBarSize()).toBe(8); + expect(getPaginationEl(0).text()).toBe('1'); + expect(getPaginationEl(-1).text()).toBe('8'); + }); }); - it('disables the "first" & activates "1" link if current-page is 1', function() { - $rootScope.currentPage = 1; - $rootScope.$digest(); - expect(element.find('li').eq(0).hasClass('disabled')).toBe(true); - expect(element.find('li').eq(1).hasClass('disabled')).toBe(false); - expect(element.find('li').eq(1).hasClass('active')).toBe(true); + describe('with just boundary & number links', function() { + beforeEach(function() { + $rootScope.directions = false; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('contains number of pages + 2 li elements', function() { + expect(getPaginationBarSize()).toBe(7); + expect(getPaginationEl(0).text()).toBe('First'); + expect(getPaginationEl(1).text()).toBe('1'); + expect(getPaginationEl(-2).text()).toBe('5'); + expect(getPaginationEl(-1).text()).toBe('Last'); + }); + + it('disables the "first" & activates "1" link if current page is 1', function() { + updateCurrentPage(1); + + expect(getPaginationEl(0)).toHaveClass('disabled'); + expect(getPaginationEl(1)).not.toHaveClass('disabled'); + expect(getPaginationEl(1)).toHaveClass('active'); + }); + + it('disables the "last" & "next" link if current page is num-pages', function() { + updateCurrentPage(5); + + expect(getPaginationEl(-2)).toHaveClass('active'); + expect(getPaginationEl(-2)).not.toHaveClass('disabled'); + expect(getPaginationEl(-1)).toHaveClass('disabled'); + }); }); - it('disables the "last" & "next" link if current-page is num-pages', function() { - $rootScope.currentPage = 5; - $rootScope.$digest(); - expect(element.find('li').eq(-2).hasClass('disabled')).toBe(false); - expect(element.find('li').eq(-2).hasClass('active')).toBe(true); - expect(element.find('li').eq(-1).hasClass('disabled')).toBe(true); + describe('`num-pages`', function() { + beforeEach(function() { + $rootScope.numpg = null; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('equals to total number of pages', function() { + expect($rootScope.numpg).toBe(5); + }); + + it('changes when total number of pages change', function() { + $rootScope.total = 73; // 8 pages + $rootScope.$digest(); + expect($rootScope.numpg).toBe(8); + }); + + it('shows minimun one page if total items are not defined and does not break binding', function() { + $rootScope.total = undefined; + $rootScope.$digest(); + expect($rootScope.numpg).toBe(1); + + $rootScope.total = 73; // 8 pages + $rootScope.$digest(); + expect($rootScope.numpg).toBe(8); + }); }); - it('changes currentPage if the "first" link is clicked', function() { - var first = element.find('li').eq(0).find('a').eq(0); - first.click(); - $rootScope.$digest(); - expect($rootScope.currentPage).toBe(1); + describe('setting `paginationConfig`', function() { + var originalConfig, paginationConfig; + beforeEach(inject(function(_uibPaginationConfig_) { + originalConfig = angular.copy(_uibPaginationConfig_); + paginationConfig = _uibPaginationConfig_; + })); + afterEach(inject(function(_uibPaginationConfig_) { + // return it to the original stat + angular.copy(originalConfig, _uibPaginationConfig_); + })); + + it('should change paging text', function() { + paginationConfig.boundaryLinks = true; + paginationConfig.directionLinks = true; + paginationConfig.firstText = 'FI'; + paginationConfig.previousText = 'PR'; + paginationConfig.nextText = 'NE'; + paginationConfig.lastText = 'LA'; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getPaginationEl(0).text()).toBe('FI'); + expect(getPaginationEl(1).text()).toBe('PR'); + expect(getPaginationEl(-2).text()).toBe('NE'); + expect(getPaginationEl(-1).text()).toBe('LA'); + }); + + it('contains number of pages + 2 li elements', function() { + paginationConfig.itemsPerPage = 5; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getPaginationBarSize()).toBe(12); + }); + + it('should take maxSize defaults into account', function() { + paginationConfig.maxSize = 2; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(getPaginationBarSize()).toBe(4); + }); + + it('should take forceEllipses defaults into account', function () { + paginationConfig.forceEllipses = true; + element = $compile('')($rootScope); + $rootScope.$digest(); + + // Should contain 2 nav buttons, 2 pages, and 2 ellipsis since the currentPage defaults to 3, which is in the middle + expect(getPaginationBarSize()).toBe(6); + }); + + it('should take boundaryLinkNumbers defaults into account', function () { + paginationConfig.boundaryLinkNumbers = true; + $rootScope.total = 88; // 9 pages + $rootScope.currentPage = 5; + element = $compile('')($rootScope); + $rootScope.$digest(); + + // Should contain 2 nav buttons, 2 pages, 2 ellipsis, and 2 extra end numbers since the currentPage is in the middle + expect(getPaginationBarSize()).toBe(9); + expect(getPaginationAsText()).toBe('Previous, 1, ..., 4, 5, 6, ..., 9, Next'); + }); }); - it('changes currentPage if the "last" link is clicked', function() { - var last = element.find('li').eq(-1).find('a').eq(0); - last.click(); - $rootScope.$digest(); - expect($rootScope.currentPage).toBe($rootScope.numPages); + describe('override configuration from attributes', function() { + beforeEach(function() { + $rootScope.pageLabel = function(id) { + return 'test_'+ id; + }; + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('contains number of pages + 4 li elements', function() { + expect(getPaginationBarSize()).toBe(9); + }); + + it('should change paging text from attribute', function() { + expect(getPaginationEl(0).text()).toBe('<<'); + expect(getPaginationEl(1).text()).toBe('<'); + expect(getPaginationEl(-2).text()).toBe('>'); + expect(getPaginationEl(-1).text()).toBe('>>'); + }); + + it('has the label of the page as text in each page item', function() { + for (var i = 1; i <= 5; i++) { + // +1 because the first element is a < + expect(getPaginationEl(i+1).text()).toEqual('test_'+i); + } + }); }); + describe('disabled with ngDisable', function() { + beforeEach(function() { + element = $compile('')($rootScope); + $rootScope.currentPage = 3; + $rootScope.$digest(); + }); + + it('should not respond to clicking', function() { + setDisabled(true); + clickPaginationEl(2); + expect($rootScope.currentPage).toBe(3); + setDisabled(false); + clickPaginationEl(2); + expect($rootScope.currentPage).toBe(2); + }); + + it('should change the class of all buttons except selected one', function() { + setDisabled(false); + expect(getPaginationEl(3).hasClass('active')).toBe(true); + expect(getPaginationEl(4).hasClass('active')).toBe(false); + setDisabled(true); + expect(getPaginationEl(3).hasClass('disabled')).toBe(false); + expect(getPaginationEl(4).hasClass('disabled')).toBe(true); + }); + }); }); -describe('pagination bypass configuration from attributes', function () { - var $rootScope, element; +describe('pagination directive', function() { + var $compile, $rootScope, element; beforeEach(module('ui.bootstrap.pagination')); - beforeEach(module('template/pagination/pagination.html')); + beforeEach(module('uib/template/pagination/pagination.html')); beforeEach(inject(function(_$compile_, _$rootScope_) { $compile = _$compile_; $rootScope = _$rootScope_; - $rootScope.numPages = 5; - $rootScope.currentPage = 3; - element = $compile('')($rootScope); - $rootScope.$digest(); })); - it('contains one ul and num-pages + 4 li elements', function() { - expect(element.find('ul').length).toBe(1); - expect(element.find('li').length).toBe(9); - }); + it('should retain the model value when total-items starts as undefined', function() { + $rootScope.currentPage = 5; + $rootScope.total = undefined; + element = $compile('')($rootScope); + $rootScope.$digest(); - it('should change paging text from attribute', function () { - expect(element.find('li').eq(0).text()).toBe('<<'); - expect(element.find('li').eq(1).text()).toBe('<'); - expect(element.find('li').eq(-2).text()).toBe('>'); - expect(element.find('li').eq(-1).text()).toBe('>>'); - }); + expect($rootScope.currentPage).toBe(5); + + $rootScope.total = 100; + $rootScope.$digest(); + expect($rootScope.currentPage).toBe(5); + }); }); diff --git a/src/paging/index.js b/src/paging/index.js new file mode 100644 index 0000000000..5564c7a750 --- /dev/null +++ b/src/paging/index.js @@ -0,0 +1,7 @@ +require('./paging'); + +var MODULE_NAME = 'ui.bootstrap.module.paging'; + +angular.module(MODULE_NAME, ['ui.bootstrap.paging']); + +module.exports = MODULE_NAME; diff --git a/src/paging/paging.js b/src/paging/paging.js new file mode 100644 index 0000000000..3abac9b4c0 --- /dev/null +++ b/src/paging/paging.js @@ -0,0 +1,92 @@ +angular.module('ui.bootstrap.paging', []) +/** + * Helper internal service for generating common controller code between the + * pager and pagination components + */ +.factory('uibPaging', ['$parse', function($parse) { + return { + create: function(ctrl, $scope, $attrs) { + ctrl.setNumPages = $attrs.numPages ? $parse($attrs.numPages).assign : angular.noop; + ctrl.ngModelCtrl = { $setViewValue: angular.noop }; // nullModelCtrl + ctrl._watchers = []; + + ctrl.init = function(ngModelCtrl, config) { + ctrl.ngModelCtrl = ngModelCtrl; + ctrl.config = config; + + ngModelCtrl.$render = function() { + ctrl.render(); + }; + + if ($attrs.itemsPerPage) { + ctrl._watchers.push($scope.$parent.$watch($attrs.itemsPerPage, function(value) { + ctrl.itemsPerPage = parseInt(value, 10); + $scope.totalPages = ctrl.calculateTotalPages(); + ctrl.updatePage(); + })); + } else { + ctrl.itemsPerPage = config.itemsPerPage; + } + + $scope.$watch('totalItems', function(newTotal, oldTotal) { + if (angular.isDefined(newTotal) || newTotal !== oldTotal) { + $scope.totalPages = ctrl.calculateTotalPages(); + ctrl.updatePage(); + } + }); + }; + + ctrl.calculateTotalPages = function() { + var totalPages = ctrl.itemsPerPage < 1 ? 1 : Math.ceil($scope.totalItems / ctrl.itemsPerPage); + return Math.max(totalPages || 0, 1); + }; + + ctrl.render = function() { + $scope.page = parseInt(ctrl.ngModelCtrl.$viewValue, 10) || 1; + }; + + $scope.selectPage = function(page, evt) { + if (evt) { + evt.preventDefault(); + } + + var clickAllowed = !$scope.ngDisabled || !evt; + if (clickAllowed && $scope.page !== page && page > 0 && page <= $scope.totalPages) { + if (evt && evt.target) { + evt.target.blur(); + } + ctrl.ngModelCtrl.$setViewValue(page); + ctrl.ngModelCtrl.$render(); + } + }; + + $scope.getText = function(key) { + return $scope[key + 'Text'] || ctrl.config[key + 'Text']; + }; + + $scope.noPrevious = function() { + return $scope.page === 1; + }; + + $scope.noNext = function() { + return $scope.page === $scope.totalPages; + }; + + ctrl.updatePage = function() { + ctrl.setNumPages($scope.$parent, $scope.totalPages); // Readonly variable + + if ($scope.page > $scope.totalPages) { + $scope.selectPage($scope.totalPages); + } else { + ctrl.ngModelCtrl.$render(); + } + }; + + $scope.$on('$destroy', function() { + while (ctrl._watchers.length) { + ctrl._watchers.shift()(); + } + }); + } + }; +}]); diff --git a/src/paging/test/paging.spec.js b/src/paging/test/paging.spec.js new file mode 100644 index 0000000000..6bafd4518a --- /dev/null +++ b/src/paging/test/paging.spec.js @@ -0,0 +1,270 @@ +describe('paging factory', function() { + var $rootScope, $scope, ctrl, attrs; + + beforeEach(module('ui.bootstrap.paging')); + beforeEach(inject(function(_$rootScope_, uibPaging) { + $rootScope = _$rootScope_; + $scope = $rootScope.$new(); + ctrl = {}; + attrs = {}; + + uibPaging.create(ctrl, $scope, attrs); + })); + + describe('init', function() { + var ngModelCtrl, config; + + beforeEach(function() { + ngModelCtrl = {}; + config = { + foo: 'bar', + itemsPerPage: 12 + }; + }); + + describe('without itemsPerPage', function() { + beforeEach(function() { + ctrl.init(ngModelCtrl, config); + }); + + it('should set the ngModel and config', function() { + expect(ctrl.ngModelCtrl).toBe(ngModelCtrl); + expect(ctrl.config).toBe(config); + }); + + it('should properly render the model', function() { + spyOn(ctrl, 'render'); + + ngModelCtrl.$render(); + + expect(ctrl.render).toHaveBeenCalled(); + }); + + it('should set to default itemsPerPage', function() { + expect(ctrl.itemsPerPage).toBe(12); + }); + + it('should update the page when total items changes', function() { + spyOn(ctrl, 'calculateTotalPages').and.returnValue(5); + spyOn(ctrl, 'updatePage'); + $rootScope.$digest(); + + expect(ctrl.calculateTotalPages.calls.count()).toBe(0); + expect(ctrl.updatePage.calls.count()).toBe(0); + + $scope.totalItems = 10; + $rootScope.$digest(); + + expect(ctrl.calculateTotalPages.calls.count()).toBe(1); + expect(ctrl.updatePage.calls.count()).toBe(1); + expect($scope.totalPages).toBe(5); + + $scope.totalItems = undefined; + $scope.totalPages = 2; + $rootScope.$digest(); + + expect(ctrl.calculateTotalPages.calls.count()).toBe(2); + expect(ctrl.updatePage.calls.count()).toBe(2); + expect($scope.totalPages).toBe(5); + }); + }); + + describe('with itemsPerPage', function() { + beforeEach(function() { + attrs.itemsPerPage = 'abc'; + $rootScope.abc = 10; + + ctrl.init(ngModelCtrl, config); + }); + + it('should update the page when itemsPerPage changes', function() { + spyOn(ctrl, 'calculateTotalPages').and.returnValue(5); + spyOn(ctrl, 'updatePage'); + $rootScope.$digest(); + + expect(ctrl.itemsPerPage).toBe(10); + expect($scope.totalPages).toBe(5); + expect(ctrl.updatePage).toHaveBeenCalled(); + }); + }); + }); + + describe('calculate totalPages', function() { + it('when itemsPerPage is less than 1', function() { + ctrl.itemsPerPage = 0; + $scope.totalItems = 101; + expect(ctrl.calculateTotalPages()).toBe(1); + }); + + it('when itemsPerPage is greater than 1', function() { + ctrl.itemsPerPage = 10; + $scope.totalItems = 101; + expect(ctrl.calculateTotalPages()).toBe(11); + }); + }); + + describe('render', function() { + it('should set page to 1 when invalid', function() { + ctrl.ngModelCtrl.$viewValue = 'abcd'; + $scope.page = 10; + + ctrl.render(); + + expect($scope.page).toBe(1); + }); + + it('should set page to view value when valid', function() { + ctrl.ngModelCtrl.$viewValue = '3'; + $scope.page = 10; + + ctrl.render(); + + expect($scope.page).toBe(3); + }); + }); + + describe('select page', function() { + beforeEach(function() { + spyOn(ctrl.ngModelCtrl, '$setViewValue'); + ctrl.ngModelCtrl.$render = jasmine.createSpy('ctrl.ngModelCtrl.$render'); + $scope.page = 5; + $scope.totalPages = 20; + }); + + it('should change the page', function() { + $scope.selectPage(12); + + expect(ctrl.ngModelCtrl.$setViewValue).toHaveBeenCalledWith(12); + expect(ctrl.ngModelCtrl.$render).toHaveBeenCalled(); + }); + + it('should not change the page to one out of range', function() { + $scope.selectPage(-1); + + expect(ctrl.ngModelCtrl.$setViewValue).not.toHaveBeenCalled(); + expect(ctrl.ngModelCtrl.$render).not.toHaveBeenCalled(); + + $scope.selectPage(21); + + expect(ctrl.ngModelCtrl.$setViewValue).not.toHaveBeenCalled(); + expect(ctrl.ngModelCtrl.$render).not.toHaveBeenCalled(); + }); + + describe('on click', function() { + var evt; + + beforeEach(function() { + evt = { + preventDefault: jasmine.createSpy('evt.preventDefault'), + target: { + blur: jasmine.createSpy('evt.target.blur') + } + }; + }); + + it('should prevent default behavior', function() { + $scope.selectPage(12, evt); + + expect(evt.preventDefault).toHaveBeenCalled(); + }); + + it('should not change the page if disabled and from an event', function() { + $scope.ngDisabled = true; + + $scope.selectPage(12, evt); + + expect(ctrl.ngModelCtrl.$setViewValue).not.toHaveBeenCalled(); + expect(ctrl.ngModelCtrl.$render).not.toHaveBeenCalled(); + }); + + it('should blur the element clicked', function() { + $scope.selectPage(12, evt); + + expect(evt.target.blur).toHaveBeenCalled(); + }); + }); + }); + + it('should get the text', function() { + $scope.fooText = 'bar'; + + expect($scope.getText('foo')).toBe('bar'); + }); + + it('should get the default text', function() { + ctrl.config = { + fooText: 'bar' + }; + + expect($scope.getText('foo')).toBe('bar'); + }); + + it('should disable previous button', function() { + $scope.page = 1; + + expect($scope.noPrevious()).toBe(true); + }); + + it('should enable previous button', function() { + $scope.page = 2; + + expect($scope.noPrevious()).toBe(false); + }); + + it('should disable next button', function() { + $scope.page = 10; + $scope.totalPages = 10; + + expect($scope.noNext()).toBe(true); + }); + + it('should enable next button', function() { + $scope.page = 9; + $scope.totalPages = 10; + + expect($scope.noNext()).toBe(false); + }); + + describe('update page', function() { + beforeEach(function() { + spyOn($scope, 'selectPage'); + ctrl.ngModelCtrl.$render = jasmine.createSpy('ctrl.ngModelCtrl.$render'); + ctrl.setNumPages = jasmine.createSpy('ctrl.setNumPages'); + $scope.totalPages = 10; + }); + + it('should select the last page if page is above total', function() { + $scope.page = 12; + + ctrl.updatePage(); + + expect(ctrl.setNumPages).toHaveBeenCalledWith($rootScope, 10); + expect($scope.selectPage).toHaveBeenCalledWith(10); + expect(ctrl.ngModelCtrl.$render).not.toHaveBeenCalled(); + }); + + it('should execute render if page is within range', function() { + $scope.page = 5; + + ctrl.updatePage(); + + expect(ctrl.setNumPages).toHaveBeenCalledWith($rootScope, 10); + expect($scope.selectPage).not.toHaveBeenCalled(); + expect(ctrl.ngModelCtrl.$render).toHaveBeenCalled(); + }); + }); + + describe('gc', function() { + it('should clear watchers', function() { + var watcher1 = jasmine.createSpy('watcher1'), + watcher2 = jasmine.createSpy('watcher2'); + ctrl._watchers = [watcher1, watcher2]; + + $scope.$destroy(); + + expect(ctrl._watchers.length).toBe(0); + expect(watcher1).toHaveBeenCalled(); + expect(watcher2).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/popover/docs/demo.html b/src/popover/docs/demo.html index ffde3bc007..1cd405176f 100644 --- a/src/popover/docs/demo.html +++ b/src/popover/docs/demo.html @@ -1,31 +1,48 @@ - - - Dynamic - Dynamic Popover : - Dynamic Popover Popup Text: - Dynamic Popover Popup Title: - {{dynamicPopoverText}} + Dynamic + + Popup Text: + - - Positional - Top - Left - Right - Bottom - Mouse + + Popup Title: + - - Triggers - Mouseenter - + + Popup Template: + - - Other - fading - title + Dynamic Popover + + Popover With Template + + + + Positional + + Popover placement + - + Popover {{placement.selected}} + + + Triggers + + Mouseenter + + + + + Other + fading + title + Toggle popover + HTML Popover + HTML Popover (inline) diff --git a/src/popover/docs/demo.js b/src/popover/docs/demo.js index 687cce0c3c..55b0f6f4b4 100644 --- a/src/popover/docs/demo.js +++ b/src/popover/docs/demo.js @@ -1,5 +1,27 @@ -var PopoverDemoCtrl = function ($scope) { - $scope.dynamicPopover = "Hello, World!"; - $scope.dynamicPopoverText = "dynamic"; - $scope.dynamicPopoverTitle = "Title"; -}; +angular.module('ui.bootstrap.demo').controller('PopoverDemoCtrl', function ($scope, $sce) { + $scope.dynamicPopover = { + content: 'Hello, World!', + templateUrl: 'myPopoverTemplate.html', + title: 'Title' + }; + + $scope.placement = { + options: [ + 'top', + 'top-left', + 'top-right', + 'bottom', + 'bottom-left', + 'bottom-right', + 'left', + 'left-top', + 'left-bottom', + 'right', + 'right-top', + 'right-bottom' + ], + selected: 'top' + }; + + $scope.htmlPopover = $sce.trustAsHtml('I can have HTML content'); +}); diff --git a/src/popover/docs/readme.md b/src/popover/docs/readme.md index d69d415891..1e12a35b50 100644 --- a/src/popover/docs/readme.md +++ b/src/popover/docs/readme.md @@ -1,25 +1,125 @@ A lightweight, extensible directive for fancy popover creation. The popover directive supports multiple placements, optional transition animation, and more. -Like the Twitter Bootstrap jQuery plugin, the popover **requires** the tooltip +Like the Bootstrap jQuery plugin, the popover **requires** the tooltip module. -The popover directives provides several optional attributes to control how it -will display: +__Note to mobile developers__: Please note that while popovers may work correctly on mobile devices (including tablets), + we have made the decision to not officially support such a use-case because it does not make sense from a UX perspective. -- `popover-title`: A string to display as a fancy title. -- `popover-placement`: Where to place it? Defaults to "top", but also accepts - "bottom", "left", "right", or "mouse". -- `popover-animation`: Should it fade in and out? Defaults to "true". -- `popover-popup-delay`: For how long should the user have to have the mouse - over the element before the popover shows (in milliseconds)? Defaults to 0. -- `popover-trigger`: What should trigger the show of the popover? See the - `tooltip` directive for supported values. -- `popover-append-to-body`: Should the tooltip be appended to `$body` instead of - the parent element? +There are three versions of the popover: `uib-popover` and `uib-popover-template`, and `uib-popover-html`: -The popover directives require the `$position` service. +* `uib-popover` - + Takes text only and will escape any HTML provided for the popover body. +* `uib-popover-html` + $ - + Takes an expression that evaluates to an HTML string. Note that this HTML is not compiled. If compilation is required, please use the `uib-popover-template` attribute option instead. *The user is responsible for ensuring the content is safe to put into the DOM!* +* `uib-popover-template` + $ - + A URL representing the location of a template to use for the popover body. Note that the contents of this template need to be wrapped in a tag, e.g., ``. -The popover directive also supports various default configurations through the -$tooltipProvider. See the [tooltip](#tooltip) section for more information. +### uib-popover-* settings +All these settings are available for the three types of popovers. + +* `popover-animation` + $ + C + _(Default: `true`, Config: `animation`)_ - + Should it fade in and out? + +* `popover-append-to-body` + $ + C + _(Default: `false`, Config: `appendToBody`)_ - + Should the popover be appended to '$body' instead of the parent element? + +* `popover-class` - + Custom class to be applied to the popover. + +* `popover-enable` + $ + _(Default: `true`)_ - + Is it enabled? It will enable or disable the configured popover-trigger. + +* `popover-is-open` + + _(Default: `false`)_ - + Whether to show the popover. + +* `popover-placement` + C + _(Default: `top`, Config: `placement`)_ - + Passing in 'auto' separated by a space before the placement will enable auto positioning, e.g: "auto bottom-left". The popover will attempt to position where it fits in the closest scrollable ancestor. Accepts: + + * `top` - popover on top, horizontally centered on host element. + * `top-left` - popover on top, left edge aligned with host element left edge. + * `top-right` - popover on top, right edge aligned with host element right edge. + * `bottom` - popover on bottom, horizontally centered on host element. + * `bottom-left` - popover on bottom, left edge aligned with host element left edge. + * `bottom-right` - popover on bottom, right edge aligned with host element right edge. + * `left` - popover on left, vertically centered on host element. + * `left-top` - popover on left, top edge aligned with host element top edge. + * `left-bottom` - popover on left, bottom edge aligned with host element bottom edge. + * `right` - popover on right, vertically centered on host element. + * `right-top` - popover on right, top edge aligned with host element top edge. + * `right-bottom` - popover on right, bottom edge aligned with host element bottom edge. + +* `popover-popup-close-delay` + C + _(Default: `0`, Config: `popupCloseDelay`)_ - + For how long should the popover remain open after the close trigger event? + +* `popover-popup-delay` + C + _(Default: `0`, Config: `popupDelay`)_ - + Popup delay in milliseconds until it opens. + +* `popover-title` - + A string to display as a fancy title. + +* `popover-trigger` + $ + _(Default: `'click'`)_ - + What should trigger a show of the popover? Supports a space separated list of event names, or objects (see below). + +**Note:** To configure the tooltips, you need to do it on `$uibTooltipProvider` (also see below). + +### Triggers + +The following show triggers are supported out of the box, along with their provided hide triggers: + +- `mouseenter`: `mouseleave` +- `click`: `click` +- `outsideClick`: `outsideClick` +- `focus`: `blur` +- `none` + +The `outsideClick` trigger will cause the popover to toggle on click, and hide when anything else is clicked. + +For any non-supported value, the trigger will be used to both show and hide the +popover. Using the 'none' trigger will disable the internal trigger(s), one can +then use the `popover-is-open` attribute exclusively to show and hide the popover. + +### $uibTooltipProvider + +Through the `$uibTooltipProvider`, you can change the way tooltips and popovers +behave by default; the attributes above always take precedence. The following +methods are available: + +* `setTriggers(obj)` + _(Example: `{ 'openTrigger': 'closeTrigger' }`)_ - + Extends the default trigger mappings mentioned above with mappings of your own. + +* `options(obj)` - + Provide a set of defaults for certain tooltip and popover attributes. Currently supports the ones with the C badge. + +### Known issues + +For Safari 7+ support, if you want to use **focus** `popover-trigger`, you need to use an anchor tag with a tab index. For example: + +``` + + Click Me + +``` diff --git a/src/popover/index-nocss.js b/src/popover/index-nocss.js new file mode 100644 index 0000000000..9d90984095 --- /dev/null +++ b/src/popover/index-nocss.js @@ -0,0 +1,11 @@ +require('../tooltip/index-nocss.js'); +require('../../template/popover/popover.html.js'); +require('../../template/popover/popover-html.html.js'); +require('../../template/popover/popover-template.html.js'); +require('./popover'); + +var MODULE_NAME = 'ui.bootstrap.module.popover'; + +angular.module(MODULE_NAME, ['ui.bootstrap.popover', 'uib/template/popover/popover.html', 'uib/template/popover/popover-html.html', 'uib/template/popover/popover-template.html']); + +module.exports = MODULE_NAME; diff --git a/src/popover/index.js b/src/popover/index.js new file mode 100644 index 0000000000..03c464a53a --- /dev/null +++ b/src/popover/index.js @@ -0,0 +1,2 @@ +require('../tooltip/tooltip.css'); +module.exports = require('./index-nocss.js'); diff --git a/src/popover/popover.js b/src/popover/popover.js index c38ff9461e..df94a4c96f 100644 --- a/src/popover/popover.js +++ b/src/popover/popover.js @@ -1,18 +1,46 @@ /** * The following features are still outstanding: popup delay, animation as a * function, placement as a function, inside, support for more triggers than - * just mouse enter/leave, html popovers, and selector delegatation. + * just mouse enter/leave, and selector delegatation. */ -angular.module( 'ui.bootstrap.popover', [ 'ui.bootstrap.tooltip' ] ) -.directive( 'popoverPopup', function () { +angular.module('ui.bootstrap.popover', ['ui.bootstrap.tooltip']) + +.directive('uibPopoverTemplatePopup', function() { return { - restrict: 'EA', - replace: true, - scope: { title: '@', content: '@', placement: '@', animation: '&', isOpen: '&' }, - templateUrl: 'template/popover/popover.html' + restrict: 'A', + scope: { uibTitle: '@', contentExp: '&', originScope: '&' }, + templateUrl: 'uib/template/popover/popover-template.html' + }; +}) + +.directive('uibPopoverTemplate', ['$uibTooltip', function($uibTooltip) { + return $uibTooltip('uibPopoverTemplate', 'popover', 'click', { + useContentExp: true + }); +}]) + +.directive('uibPopoverHtmlPopup', function() { + return { + restrict: 'A', + scope: { contentExp: '&', uibTitle: '@' }, + templateUrl: 'uib/template/popover/popover-html.html' }; }) -.directive( 'popover', [ '$compile', '$timeout', '$parse', '$window', '$tooltip', function ( $compile, $timeout, $parse, $window, $tooltip ) { - return $tooltip( 'popover', 'popover', 'click' ); -}]); +.directive('uibPopoverHtml', ['$uibTooltip', function($uibTooltip) { + return $uibTooltip('uibPopoverHtml', 'popover', 'click', { + useContentExp: true + }); +}]) + +.directive('uibPopoverPopup', function() { + return { + restrict: 'A', + scope: { uibTitle: '@', content: '@' }, + templateUrl: 'uib/template/popover/popover.html' + }; +}) + +.directive('uibPopover', ['$uibTooltip', function($uibTooltip) { + return $uibTooltip('uibPopover', 'popover', 'click'); +}]); diff --git a/src/popover/test/popover-html.spec.js b/src/popover/test/popover-html.spec.js new file mode 100644 index 0000000000..fd43e51f15 --- /dev/null +++ b/src/popover/test/popover-html.spec.js @@ -0,0 +1,212 @@ +describe('popover', function() { + var elm, + elmBody, + scope, + elmScope, + tooltipScope; + + // load the popover code + beforeEach(module('ui.bootstrap.popover')); + + // load the template + beforeEach(module('uib/template/popover/popover-html.html')); + + beforeEach(inject(function($rootScope, $compile, $sce, _$document_) { + $document = _$document_; + elmBody = angular.element( + 'Selector Text' + ); + + scope = $rootScope; + scope.template = $sce.trustAsHtml('My template'); + $compile(elmBody)(scope); + scope.$digest(); + $document.find('body').append(elmBody); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + })); + + afterEach(function() { + $document.off('keypress'); + }); + + it('should not be open initially', inject(function() { + expect(tooltipScope.isOpen).toBe(false); + + // We can only test *that* the popover-popup element wasn't created as the + // implementation is templated and replaced. + expect(elmBody.children().length).toBe(1); + })); + + it('should open on click', inject(function() { + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + // We can only test *that* the popover-popup element was created as the + // implementation is templated and replaced. + expect(elmBody.children().length).toBe(2); + })); + + it('should close on second click', inject(function() { + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(false); + })); + + it('should not open on click if template is empty', inject(function() { + scope.template = null; + scope.$digest(); + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(false); + + expect(elmBody.children().length).toBe(1); + })); + + it('should show updated text', inject(function($sce) { + scope.template = $sce.trustAsHtml('My template'); + scope.$digest(); + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + expect(elmBody.children().eq(1).text().trim()).toBe('My template'); + + scope.template = $sce.trustAsHtml('Another template'); + scope.$digest(); + + expect(elmBody.children().eq(1).text().trim()).toBe('Another template'); + })); + + it('should hide popover when template becomes empty', inject(function($timeout) { + elm.trigger('click'); + tooltipScope.$digest(); + $timeout.flush(0); + expect(tooltipScope.isOpen).toBe(true); + + scope.template = ''; + scope.$digest(); + + expect(tooltipScope.isOpen).toBe(false); + + $timeout.flush(); + expect(elmBody.children().length).toBe(1); + })); + + + it('should not unbind event handlers created by other directives - issue 456', inject(function($compile) { + scope.click = function() { + scope.clicked = !scope.clicked; + }; + + elmBody = angular.element( + '' + ); + $compile(elmBody)(scope); + scope.$digest(); + + elm = elmBody.find('input'); + + elm.trigger('mouseenter'); + tooltipScope.$digest(); + elm.trigger('mouseleave'); + tooltipScope.$digest(); + expect(scope.clicked).toBeFalsy(); + + elm.click(); + expect(scope.clicked).toBeTruthy(); + })); + + it('should popup with animate class by default', inject(function() { + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + expect(elmBody.children().eq(1)).toHaveClass('fade'); + })); + + it('should popup without animate class when animation disabled', inject(function($compile) { + elmBody = angular.element( + 'Selector Text' + ); + + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + expect(elmBody.children().eq(1)).not.toHaveClass('fade'); + })); + + it ('should display the title', inject(function($compile) { + elmBody = angular.element( + 'Selector Text' + ); + + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + + elm.trigger('click'); + scope.$digest(); + + var titleEl = elmBody.find('.popover-title'); + expect(titleEl.text()).toBe('popover title'); + })); + + describe('supports options', function() { + describe('placement', function() { + it('can specify an alternative, valid placement', inject(function($compile) { + elmBody = angular.element( + 'Trigger here' + ); + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + expect(elmBody.children().length).toBe(2); + var ttipElement = elmBody.find('div.popover'); + expect(ttipElement).toHaveClass('left'); + })); + + }); + + describe('class', function() { + it('can specify a custom class', inject(function($compile) { + elmBody = angular.element( + 'Trigger here' + ); + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + expect(elmBody.children().length).toBe(2); + var ttipElement = elmBody.find('div.popover'); + expect(ttipElement).toHaveClass('custom'); + })); + }); + }); +}); diff --git a/src/popover/test/popover-template.spec.js b/src/popover/test/popover-template.spec.js new file mode 100644 index 0000000000..b46e668b06 --- /dev/null +++ b/src/popover/test/popover-template.spec.js @@ -0,0 +1,152 @@ +describe('popover template', function() { + var elm, + elmBody, + scope, + elmScope, + tooltipScope, + $document; + + // load the popover code + beforeEach(module('ui.bootstrap.popover')); + + // load the template + beforeEach(module('uib/template/popover/popover.html')); + beforeEach(module('uib/template/popover/popover-template.html')); + + beforeEach(inject(function($templateCache) { + $templateCache.put('myUrl', [200, '{{ myTemplateText }}', {}]); + })); + + beforeEach(inject(function($rootScope, $compile, _$document_) { + $document = _$document_; + elmBody = angular.element( + 'Selector Text' + ); + + scope = $rootScope; + $compile(elmBody)(scope); + $document.find('body').append(elmBody); + scope.templateUrl = 'myUrl'; + + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + })); + + afterEach(function() { + $document.off('keypress'); + elmBody.remove(); + }); + + it('should open on click', inject(function() { + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + expect(elmBody.children().length ).toBe(2); + })); + + it('should not open on click if templateUrl is empty', inject(function() { + scope.templateUrl = null; + scope.$digest(); + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(false); + + expect(elmBody.children().length).toBe(1); + })); + + it('should show updated text', inject(function() { + scope.myTemplateText = 'some text'; + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + scope.$digest(); + expect(elmBody.children().eq(1).text().trim()).toBe('some text'); + + scope.myTemplateText = 'new text'; + scope.$digest(); + + expect(elmBody.children().eq(1).text().trim()).toBe('new text'); + })); + + it('should hide popover when template becomes empty', inject(function($timeout) { + elm.trigger('click'); + tooltipScope.$digest(); + $timeout.flush(0); + expect(tooltipScope.isOpen).toBe(true); + + scope.templateUrl = ''; + scope.$digest(); + + expect(tooltipScope.isOpen).toBe(false); + + $timeout.flush(); + expect(elmBody.children().length).toBe(1); + })); + + it ('should display the title', inject(function($compile) { + elmBody = angular.element( + 'Selector Text' + ); + + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + + elm.trigger('click'); + scope.$digest(); + + var titleEl = elmBody.find('.popover-title'); + expect(titleEl.text()).toBe('popover title'); + })); + + describe('supports options', function() { + describe('placement', function() { + it('can specify an alternative, valid placement', inject(function($compile) { + elmBody = angular.element( + 'Trigger' + ); + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + expect(elmBody.children().length).toBe(2); + var ttipElement = elmBody.find('div.popover'); + expect(ttipElement).toHaveClass('left'); + })); + + }); + + describe('class', function() { + it('can specify a custom class', inject(function($compile) { + elmBody = angular.element( + 'Trigger' + ); + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + expect(elmBody.children().length).toBe(2); + var ttipElement = elmBody.find('div.popover'); + expect(ttipElement).toHaveClass('custom'); + })); + }); + }); +}); diff --git a/src/popover/test/popover.spec.js b/src/popover/test/popover.spec.js new file mode 100644 index 0000000000..c9d4da3fc2 --- /dev/null +++ b/src/popover/test/popover.spec.js @@ -0,0 +1,212 @@ +describe('popover', function() { + var elm, + elmBody, + scope, + elmScope, + tooltipScope, + $document; + + // load the popover code + beforeEach(module('ui.bootstrap.popover')); + + // load the template + beforeEach(module('uib/template/popover/popover.html')); + + beforeEach(inject(function($rootScope, $compile, _$document_) { + $document = _$document_; + elmBody = angular.element( + 'Selector Text' + ); + + scope = $rootScope; + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + })); + + afterEach(function() { + $document.off('keypress'); + }); + + it('should not be open initially', inject(function() { + expect(tooltipScope.isOpen).toBe(false); + + // We can only test *that* the popover-popup element wasn't created as the + // implementation is templated and replaced. + expect(elmBody.children().length).toBe(1); + })); + + it('should open on click', inject(function() { + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + // We can only test *that* the popover-popup element was created as the + // implementation is templated and replaced. + expect(elmBody.children().length).toBe(2); + })); + + it('should close on second click', inject(function() { + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(false); + })); + + it('should not unbind event handlers created by other directives - issue 456', inject(function($compile) { + scope.click = function() { + scope.clicked = !scope.clicked; + }; + + elmBody = angular.element( + '' + ); + $compile(elmBody)(scope); + scope.$digest(); + + elm = elmBody.find('input'); + + elm.trigger('mouseenter'); + elm.trigger('mouseleave'); + expect(scope.clicked).toBeFalsy(); + + elm.click(); + expect(scope.clicked).toBeTruthy(); + })); + + it('should popup with animate class by default', inject(function() { + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + expect(elmBody.children().eq(1)).toHaveClass('fade'); + })); + + it('should popup without animate class when animation disabled', inject(function($compile) { + elmBody = angular.element( + 'Selector Text' + ); + + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + expect(elmBody.children().eq(1)).not.toHaveClass('fade'); + })); + + it ('should display the title', inject(function($compile) { + elmBody = angular.element( + 'Trigger here' + ); + $compile(elmBody)(scope); + scope.$digest(); + + elm = elmBody.find('span'); + elm.trigger('click'); + scope.$digest(); + + var titleEl = elmBody.find('.popover-title'); + expect(titleEl.text()).toBe('popover title'); + })); + + it ('should display the content', inject(function($compile) { + elmBody = angular.element( + 'Trigger here' + ); + $compile(elmBody)(scope); + scope.$digest(); + + elm = elmBody.find('span'); + elm.trigger('click'); + scope.$digest(); + + var contentEl = elmBody.find('.popover-content'); + expect(contentEl.text()).toBe('popover text'); + })); + + describe('supports options', function() { + describe('placement', function() { + it('can specify an alternative, valid placement', inject(function($compile) { + elmBody = angular.element( + 'Trigger here' + ); + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + expect(elmBody.children().length).toBe(2); + var ttipElement = elmBody.find('div.popover'); + expect(ttipElement).toHaveClass('left'); + })); + }); + + describe('class', function() { + it('can specify a custom class', inject(function($compile) { + elmBody = angular.element( + 'Trigger here' + ); + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + elm.trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + + expect(elmBody.children().length).toBe(2); + var ttipElement = elmBody.find('div.popover'); + expect(ttipElement).toHaveClass('custom'); + })); + }); + + describe('is-open', function() { + beforeEach(inject(function ($compile) { + scope.isOpen = false; + elmBody = angular.element( + 'Trigger here' + ); + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + })); + + it('should show and hide with the controller value', function() { + expect(tooltipScope.isOpen).toBe(false); + elmScope.isOpen = true; + elmScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + elmScope.isOpen = false; + elmScope.$digest(); + expect(tooltipScope.isOpen).toBe(false); + }); + + it('should update the controller value', function() { + elm.trigger('click'); + tooltipScope.$digest(); + expect(elmScope.isOpen).toBe(true); + elm.trigger('click'); + tooltipScope.$digest(); + expect(elmScope.isOpen).toBe(false); + }); + }); + }); +}); diff --git a/src/popover/test/popoverSpec.js b/src/popover/test/popoverSpec.js deleted file mode 100644 index 5f1fdc7f05..0000000000 --- a/src/popover/test/popoverSpec.js +++ /dev/null @@ -1,71 +0,0 @@ -describe('popover', function() { - var elm, - elmBody, - scope, - elmScope; - - // load the popover code - beforeEach(module('ui.bootstrap.popover')); - - // load the template - beforeEach(module('template/popover/popover.html')); - - beforeEach(inject(function($rootScope, $compile) { - elmBody = angular.element( - 'Selector Text' - ); - - scope = $rootScope; - $compile(elmBody)(scope); - scope.$digest(); - elm = elmBody.find('span'); - elmScope = elm.scope(); - })); - - it('should not be open initially', inject(function() { - expect( elmScope.tt_isOpen ).toBe( false ); - - // We can only test *that* the popover-popup element wasn't created as the - // implementation is templated and replaced. - expect( elmBody.children().length ).toBe( 1 ); - })); - - it('should open on click', inject(function() { - elm.trigger( 'click' ); - expect( elmScope.tt_isOpen ).toBe( true ); - - // We can only test *that* the popover-popup element was created as the - // implementation is templated and replaced. - expect( elmBody.children().length ).toBe( 2 ); - })); - - it('should close on second click', inject(function() { - elm.trigger( 'click' ); - elm.trigger( 'click' ); - expect( elmScope.tt_isOpen ).toBe( false ); - })); - - it('should not unbind event handlers created by other directives - issue 456', inject( function( $compile ) { - - scope.click = function() { - scope.clicked = !scope.clicked; - }; - - elmBody = angular.element( - '' - ); - $compile(elmBody)(scope); - scope.$digest(); - - elm = elmBody.find('input'); - - elm.trigger( 'mouseenter' ); - elm.trigger( 'mouseleave' ); - expect(scope.clicked).toBeFalsy(); - - elm.click(); - expect(scope.clicked).toBeTruthy(); - })); -}); - - diff --git a/src/position/docs/demo.html b/src/position/docs/demo.html new file mode 100644 index 0000000000..fcc063f433 --- /dev/null +++ b/src/position/docs/demo.html @@ -0,0 +1,34 @@ + + $uibPosition service + + + + Parent scrollable + + + + + Parent relative + + + Get values + + + Demo element + + + + offsetParent: {{elemVals.offsetParent}} + + scrollParent: {{elemVals.scrollParent}} + + scrollbarWidth: {{scrollbarWidth}} + + position: {{elemVals.position}} + + offset: {{elemVals.offset}} + + viewportOffset: {{elemVals.viewportOffset}} + + positionElements: {{elemVals.positionElements}} + \ No newline at end of file diff --git a/src/position/docs/demo.js b/src/position/docs/demo.js new file mode 100644 index 0000000000..8be1343de4 --- /dev/null +++ b/src/position/docs/demo.js @@ -0,0 +1,27 @@ +angular.module('ui.bootstrap.demo').controller('PositionDemoCtrl', function ($scope, $window, $uibPosition) { + + $scope.elemVals = {}; + $scope.parentScrollable = true; + $scope.parentRelative = true; + + $scope.getValues = function() { + var divEl = $window.document.querySelector('#posdemodiv'); + var btnEl = $window.document.querySelector('#posdemobtn'); + + var offsetParent = $uibPosition.offsetParent(divEl); + $scope.elemVals.offsetParent = 'type: ' + offsetParent.tagName + ', id: ' + offsetParent.id; + + var scrollParent = $uibPosition.scrollParent(divEl); + $scope.elemVals.scrollParent = 'type: ' + scrollParent.tagName + ', id: ' + scrollParent.id; + + $scope.scrollbarWidth = $uibPosition.scrollbarWidth(); + + $scope.elemVals.position = $uibPosition.position(divEl); + + $scope.elemVals.offset = $uibPosition.offset(divEl); + + $scope.elemVals.viewportOffset = $uibPosition.viewportOffset(divEl); + + $scope.elemVals.positionElements = $uibPosition.positionElements(btnEl, divEl, 'auto bottom-left'); + }; +}); \ No newline at end of file diff --git a/src/position/docs/readme.md b/src/position/docs/readme.md new file mode 100644 index 0000000000..1dad79aca0 --- /dev/null +++ b/src/position/docs/readme.md @@ -0,0 +1,336 @@ +The `$uibPosition` service provides a set of DOM utilities used internally to absolute-position an element in relation to another element (tooltips, popovers, typeaheads etc...). + +#### getRawNode(element) + +Takes a jQuery/jqLite element and converts it to a raw DOM element. + +##### parameters + +* `element` + _(Type: `object`)_ - + The element to convert. + +##### returns + +* _(Type: `element`)_ - + A raw DOM element. + +#### parseStyle(element) + +Parses a numeric style value to a number. Strips units and will return 0 for invalid (NaN) numbers. + +##### parameters + +* `value` + _(Type: `string`)_ - + The style value to parse. + +##### returns + +* _(Type: `number`)_ - + The numeric value of the style property. + +#### offsetParent(element) + +Gets the closest positioned ancestor. + +##### parameters + +* `element` + _(Type: `element`)_ - + The element to get the offset parent for. + +##### returns + +* _(Type: `element`)_ - + The closest positioned ancestor. + +#### scrollbarWidth(isBody) + +Calculates the browser scrollbar width and caches the result for future calls. Concept from the TWBS measureScrollbar() function in [modal.js](https://github.com/twbs/bootstrap/blob/master/js/modal.js). + +##### parameters + +* `isBody` + _(Type: `boolean`, Default: `false`, optional)_ - Is the requested scrollbar width for the body/html element. IE and Edge overlay the scrollbar on the body/html element and should be considered 0. + +##### returns + +* _(Type: `number`)_ - + The width of the browser scrollbar. + +#### scrollbarPadding(element) + +Calculates the padding required to replace the scrollbar on an element. + +##### parameters + +* 'element' _(Type: `element`)_ - The element to calculate the padding on (should be a scrollable element). + +##### returns + +An object with the following properties: + +* `scrollbarWidth` + _(Type: `number`)_ - + The width of the scrollbar. + +* `widthOverflow` + _(Type: `boolean`)_ - + Whether the width is overflowing. + +* `right` + _(Type: `number`)_ - + The total right padding required to replace the scrollbar. + +* `originalRight` + _(Type: `number`)_ - + The oringal right padding on the element. + +* `heightOverflow` + _(Type: `boolean`)_ - + Whether the height is overflowing. + +* `bottom` + _(Type: `number`)_ - + The total bottom padding required to replace the scrollbar. + +* `originalBottom` + _(Type: `number`)_ - + The oringal bottom padding on the element. + +#### isScrollable(element, includeHidden) + +Determines if an element is scrollable. + +##### parameters + +* `element` + _(Type: `element`)_ - + The element to check. + +* `includeHidden` + _(Type: `boolean`, Default: `false`, optional)_ - Should scroll style of 'hidden' be considered. + +##### returns + +* _(Type: `boolean`)_ - + Whether the element is scrollable. + +#### scrollParent(element, includeHidden, includeSelf) + +Gets the closest scrollable ancestor. Concept from the jQueryUI [scrollParent.js](https://github.com/jquery/jquery-ui/blob/master/ui/scroll-parent.js). + +##### parameters + +* `element` + _(Type: `element`)_ - + The element to get the closest scrollable ancestor for. + +* `includeHidden` + _(Type: `boolean`, Default: `false`, optional)_ - Should scroll style of 'hidden' be considered. + +* `includeSelf` + _(Type: `boolean`, Default: `false`, optional)_ - Should the element passed in be included in the scrollable lookup. + +##### returns + +* _(Type: `element`)_ - + The closest scrollable ancestor. + +#### position(element, includeMargins) + +A read-only equivalent of jQuery's [position](http://api.jquery.com/position/) function, distance to closest positioned ancestor. Does not account for margins by default like jQuery's position. + +##### parameters + +* `element` _(Type: `element`)_ - + The element to get the position for. + +* `includeMargins` _(Type: `boolean`, Default: `false`, optional)_ - + Should margins be accounted for. + +##### returns + +An object with the following properties: + +* `width` + _(Type: `number`)_ - + The width of the element. + +* `height` + _(Type: `number`)_ - + The height of the element. + +* `top` + _(Type: `number`)_ - + Distance to top edge of offset parent. + +* `left` + _(Type: `number`)_ - + Distance to left edge of offset parent. + +#### offset(element) + +A read-only equivalent of jQuery's [offset](http://api.jquery.com/offset/) function, distance to viewport. + +##### parameters + +* `element` + _(Type: `element`)_ - + The element to get the offset for. + +##### returns + +An object with the following properties: + +* `width` + _(Type: `number`)_ - + The width of the element. + +* `height` + _(Type: `number`)_ - + The height of the element. + +* `top` + _(Type: `number`)_ - + Distance to top edge of the viewport. + +* `left` + _(Type: `number`)_ - + Distance to left edge of the viewport. + +#### viewportOffset(element, useDocument, includePadding) + +Gets the elements available space relative to the closest scrollable ancestor. Accounts for padding, border, and scrollbar width. +Right and bottom dimensions represent the distance to the respective edge of the viewport element, not the top and left edge. +If the element edge extends beyond the viewport, a negative value will be reported. + +##### parameters + +* `element` + _(Type: `element`)_ - + The element to get the viewport offset for. + +* `useDocument` + _(Type: `boolean`, Default: `false`, optional)_ - + Should the viewport be the document element instead of the first scrollable element. + +* `includePadding` + _(Type: `boolean`, Default: `true`, optional)_ - + Should the padding on the viewport element be accounted for, default is true. + +##### returns + +An object with the following properties: + +* `top` + _(Type: `number`)_ - + Distance to top content edge of the viewport. + +* `bottom` + _(Type: `number`)_ - + Distance to bottom content edge of the viewport. + +* `left` + _(Type: `number`)_ - + Distance to left content edge of the viewport. + +* `right` + _(Type: `number`)_ - + Distance to right content edge of the viewport. + +#### parsePlacement(placement) + +Gets an array of placement values parsed from a placement string. Along with the 'auto' indicator, supported placement strings are: + +* top: element on top, horizontally centered on host element. +* top-left: element on top, left edge aligned with host element left edge. +* top-right: element on top, right edge aligned with host element right edge. +* bottom: element on bottom, horizontally centered on host element. +* bottom-left: element on bottom, left edge aligned with host element left edge. +* bottom-right: element on bottom, right edge aligned with host element right edge. +* left: element on left, vertically centered on host element. +* left-top: element on left, top edge aligned with host element top edge. +* left-bottom: element on left, bottom edge aligned with host element bottom edge. +* right: element on right, vertically centered on host element. +* right-top: element on right, top edge aligned with host element top edge. +* right-bottom: element on right, bottom edge aligned with host element bottom edge. + +A placement string with an 'auto' indicator is expected to be space separated from the placement, i.e: 'auto bottom-left'. +If the primary and secondary placement values do not match 'top, bottom, left, right' then 'top' will be the primary placement and +'center' will be the secondary placement. If 'auto' is passed, true will be returned as the 3rd value of the array. + +##### parameters + +* `placement` + _(Type: `string`, Example: `auto top-left`)_ - + The placement string to parse. + +##### returns + +An array with the following values: + +* `[0]` + _(Type: `string`)_ - + The primary placement. + +* `[1]` + _(Type: `string`)_ - + The secondary placement. + +* `[2]` + _(Type: `boolean`)_ - + Is auto place enabled. + +#### positionElements(hostElement, targetElement, placement, appendToBody) + +Gets gets coordinates for an element to be positioned relative to another element. + +##### parameters + +* `hostElement` + _(Type: `element`)_ - + The element to position against. + +* `targetElement` + _(Type: `element`)_ - + The element to position. + +* `placement` + _(Type: `string`, Default: `top`, optional)_ - + The placement for the target element. See the parsePlacement() function for available options. If 'auto' placement is used, the viewportOffset() function is used to decide where the targetElement will fit. + +* `appendToBody` + _(Type: `boolean`, Default: `false`, optional)_ - + Should the coordinates be cacluated from the body element. + +##### returns + +An object with the following properties: + +* `top` + _(Type: `number`)_ - + The targetElement top value. + +* `left` + _(Type: `number`)_ - + The targetElement left value. + +* `right` + _(Type: `number`)_ - + The resolved placement with 'auto' removed. + +#### positionArrow(element, placement) + +Positions the tooltip and popover arrow elements when using placement options beyond the standard top, left, bottom, or right. + +##### parameters + +* `element` + _(Type: `element`)_ - + The element to position the arrow element for. + +* `placement` + _(Type: `string`)_ - + The placement for the element. diff --git a/src/position/index-nocss.js b/src/position/index-nocss.js new file mode 100644 index 0000000000..10b8e9e506 --- /dev/null +++ b/src/position/index-nocss.js @@ -0,0 +1,7 @@ +require('./position'); + +var MODULE_NAME = 'ui.bootstrap.module.position'; + +angular.module(MODULE_NAME, ['ui.bootstrap.position']); + +module.exports = MODULE_NAME; diff --git a/src/position/index.js b/src/position/index.js new file mode 100644 index 0000000000..400b560ca1 --- /dev/null +++ b/src/position/index.js @@ -0,0 +1,2 @@ +require('./position.css'); +module.exports = require('./index-nocss.js'); diff --git a/src/position/position.css b/src/position/position.css new file mode 100644 index 0000000000..41fb4a2d87 --- /dev/null +++ b/src/position/position.css @@ -0,0 +1,19 @@ +.uib-position-measure { + display: block !important; + visibility: hidden !important; + position: absolute !important; + top: -9999px !important; + left: -9999px !important; +} + +.uib-position-scrollbar-measure { + position: absolute !important; + top: -9999px !important; + width: 50px !important; + height: 50px !important; + overflow: scroll !important; +} + +.uib-position-body-scrollbar-measure { + overflow: scroll !important; +} \ No newline at end of file diff --git a/src/position/position.js b/src/position/position.js index 727a85e85d..3ab066abb5 100644 --- a/src/position/position.js +++ b/src/position/position.js @@ -1,93 +1,620 @@ angular.module('ui.bootstrap.position', []) /** - * A set of utility methods that can be use to retrieve position of DOM elements. - * It is meant to be used where we need to absolute-position DOM elements in - * relation to other, existing elements (this is the case for tooltips, popovers, + * A set of utility methods for working with the DOM. + * It is meant to be used where we need to absolute-position elements in + * relation to another element (this is the case for tooltips, popovers, * typeahead suggestions etc.). */ - .factory('$position', ['$document', '$window', function ($document, $window) { - - var mouseX, mouseY; - - $document.bind('mousemove', function mouseMoved(event) { - mouseX = event.pageX; - mouseY = event.pageY; - }); - - function getStyle(el, cssprop) { - if (el.currentStyle) { //IE - return el.currentStyle[cssprop]; - } else if ($window.getComputedStyle) { - return $window.getComputedStyle(el)[cssprop]; - } - // finally try and get inline style - return el.style[cssprop]; - } - + .factory('$uibPosition', ['$document', '$window', function($document, $window) { /** - * Checks if a given element is statically positioned - * @param element - raw DOM element + * Used by scrollbarWidth() function to cache scrollbar's width. + * Do not access this variable directly, use scrollbarWidth() instead. */ - function isStaticPositioned(element) { - return (getStyle(element, "position") || 'static' ) === 'static'; - } - + var SCROLLBAR_WIDTH; /** - * returns the closest, non-statically positioned parentOffset of a given element - * @param element + * scrollbar on body and html element in IE and Edge overlay + * content and should be considered 0 width. */ - var parentOffsetEl = function (element) { - var docDomEl = $document[0]; - var offsetParent = element.offsetParent || docDomEl; - while (offsetParent && offsetParent !== docDomEl && isStaticPositioned(offsetParent) ) { - offsetParent = offsetParent.offsetParent; - } - return offsetParent || docDomEl; + var BODY_SCROLLBAR_WIDTH; + var OVERFLOW_REGEX = { + normal: /(auto|scroll)/, + hidden: /(auto|scroll|hidden)/ }; + var PLACEMENT_REGEX = { + auto: /\s?auto?\s?/i, + primary: /^(top|bottom|left|right)$/, + secondary: /^(top|bottom|left|right|center)$/, + vertical: /^(top|bottom)$/ + }; + var BODY_REGEX = /(HTML|BODY)/; return { + + /** + * Provides a raw DOM element from a jQuery/jQLite element. + * + * @param {element} elem - The element to convert. + * + * @returns {element} A HTML element. + */ + getRawNode: function(elem) { + return elem.nodeName ? elem : elem[0] || elem; + }, + + /** + * Provides a parsed number for a style property. Strips + * units and casts invalid numbers to 0. + * + * @param {string} value - The style value to parse. + * + * @returns {number} A valid number. + */ + parseStyle: function(value) { + value = parseFloat(value); + return isFinite(value) ? value : 0; + }, + + /** + * Provides the closest positioned ancestor. + * + * @param {element} element - The element to get the offest parent for. + * + * @returns {element} The closest positioned ancestor. + */ + offsetParent: function(elem) { + elem = this.getRawNode(elem); + + var offsetParent = elem.offsetParent || $document[0].documentElement; + + function isStaticPositioned(el) { + return ($window.getComputedStyle(el).position || 'static') === 'static'; + } + + while (offsetParent && offsetParent !== $document[0].documentElement && isStaticPositioned(offsetParent)) { + offsetParent = offsetParent.offsetParent; + } + + return offsetParent || $document[0].documentElement; + }, + + /** + * Provides the scrollbar width, concept from TWBS measureScrollbar() + * function in https://github.com/twbs/bootstrap/blob/master/js/modal.js + * In IE and Edge, scollbar on body and html element overlay and should + * return a width of 0. + * + * @returns {number} The width of the browser scollbar. + */ + scrollbarWidth: function(isBody) { + if (isBody) { + if (angular.isUndefined(BODY_SCROLLBAR_WIDTH)) { + var bodyElem = $document.find('body'); + bodyElem.addClass('uib-position-body-scrollbar-measure'); + BODY_SCROLLBAR_WIDTH = $window.innerWidth - bodyElem[0].clientWidth; + BODY_SCROLLBAR_WIDTH = isFinite(BODY_SCROLLBAR_WIDTH) ? BODY_SCROLLBAR_WIDTH : 0; + bodyElem.removeClass('uib-position-body-scrollbar-measure'); + } + return BODY_SCROLLBAR_WIDTH; + } + + if (angular.isUndefined(SCROLLBAR_WIDTH)) { + var scrollElem = angular.element(''); + $document.find('body').append(scrollElem); + SCROLLBAR_WIDTH = scrollElem[0].offsetWidth - scrollElem[0].clientWidth; + SCROLLBAR_WIDTH = isFinite(SCROLLBAR_WIDTH) ? SCROLLBAR_WIDTH : 0; + scrollElem.remove(); + } + + return SCROLLBAR_WIDTH; + }, + + /** + * Provides the padding required on an element to replace the scrollbar. + * + * @returns {object} An object with the following properties: + * + * **scrollbarWidth**: the width of the scrollbar + * **widthOverflow**: whether the the width is overflowing + * **right**: the amount of right padding on the element needed to replace the scrollbar + * **rightOriginal**: the amount of right padding currently on the element + * **heightOverflow**: whether the the height is overflowing + * **bottom**: the amount of bottom padding on the element needed to replace the scrollbar + * **bottomOriginal**: the amount of bottom padding currently on the element + * + */ + scrollbarPadding: function(elem) { + elem = this.getRawNode(elem); + + var elemStyle = $window.getComputedStyle(elem); + var paddingRight = this.parseStyle(elemStyle.paddingRight); + var paddingBottom = this.parseStyle(elemStyle.paddingBottom); + var scrollParent = this.scrollParent(elem, false, true); + var scrollbarWidth = this.scrollbarWidth(BODY_REGEX.test(scrollParent.tagName)); + + return { + scrollbarWidth: scrollbarWidth, + widthOverflow: scrollParent.scrollWidth > scrollParent.clientWidth, + right: paddingRight + scrollbarWidth, + originalRight: paddingRight, + heightOverflow: scrollParent.scrollHeight > scrollParent.clientHeight, + bottom: paddingBottom + scrollbarWidth, + originalBottom: paddingBottom + }; + }, + + /** + * Checks to see if the element is scrollable. + * + * @param {element} elem - The element to check. + * @param {boolean=} [includeHidden=false] - Should scroll style of 'hidden' be considered, + * default is false. + * + * @returns {boolean} Whether the element is scrollable. + */ + isScrollable: function(elem, includeHidden) { + elem = this.getRawNode(elem); + + var overflowRegex = includeHidden ? OVERFLOW_REGEX.hidden : OVERFLOW_REGEX.normal; + var elemStyle = $window.getComputedStyle(elem); + return overflowRegex.test(elemStyle.overflow + elemStyle.overflowY + elemStyle.overflowX); + }, + + /** + * Provides the closest scrollable ancestor. + * A port of the jQuery UI scrollParent method: + * https://github.com/jquery/jquery-ui/blob/master/ui/scroll-parent.js + * + * @param {element} elem - The element to find the scroll parent of. + * @param {boolean=} [includeHidden=false] - Should scroll style of 'hidden' be considered, + * default is false. + * @param {boolean=} [includeSelf=false] - Should the element being passed be + * included in the scrollable llokup. + * + * @returns {element} A HTML element. + */ + scrollParent: function(elem, includeHidden, includeSelf) { + elem = this.getRawNode(elem); + + var overflowRegex = includeHidden ? OVERFLOW_REGEX.hidden : OVERFLOW_REGEX.normal; + var documentEl = $document[0].documentElement; + var elemStyle = $window.getComputedStyle(elem); + if (includeSelf && overflowRegex.test(elemStyle.overflow + elemStyle.overflowY + elemStyle.overflowX)) { + return elem; + } + var excludeStatic = elemStyle.position === 'absolute'; + var scrollParent = elem.parentElement || documentEl; + + if (scrollParent === documentEl || elemStyle.position === 'fixed') { + return documentEl; + } + + while (scrollParent.parentElement && scrollParent !== documentEl) { + var spStyle = $window.getComputedStyle(scrollParent); + if (excludeStatic && spStyle.position !== 'static') { + excludeStatic = false; + } + + if (!excludeStatic && overflowRegex.test(spStyle.overflow + spStyle.overflowY + spStyle.overflowX)) { + break; + } + scrollParent = scrollParent.parentElement; + } + + return scrollParent; + }, + /** * Provides read-only equivalent of jQuery's position function: - * http://api.jquery.com/position/ + * http://api.jquery.com/position/ - distance to closest positioned + * ancestor. Does not account for margins by default like jQuery position. + * + * @param {element} elem - The element to caclulate the position on. + * @param {boolean=} [includeMargins=false] - Should margins be accounted + * for, default is false. + * + * @returns {object} An object with the following properties: + * + * **width**: the width of the element + * **height**: the height of the element + * **top**: distance to top edge of offset parent + * **left**: distance to left edge of offset parent + * */ - position: function (element) { - var elBCR = this.offset(element); - var offsetParentBCR = { top: 0, left: 0 }; - var offsetParentEl = parentOffsetEl(element[0]); - if (offsetParentEl != $document[0]) { - offsetParentBCR = this.offset(angular.element(offsetParentEl)); - offsetParentBCR.top += offsetParentEl.clientTop - offsetParentEl.scrollTop; - offsetParentBCR.left += offsetParentEl.clientLeft - offsetParentEl.scrollLeft; + position: function(elem, includeMagins) { + elem = this.getRawNode(elem); + + var elemOffset = this.offset(elem); + if (includeMagins) { + var elemStyle = $window.getComputedStyle(elem); + elemOffset.top -= this.parseStyle(elemStyle.marginTop); + elemOffset.left -= this.parseStyle(elemStyle.marginLeft); + } + var parent = this.offsetParent(elem); + var parentOffset = {top: 0, left: 0}; + + if (parent !== $document[0].documentElement) { + parentOffset = this.offset(parent); + parentOffset.top += parent.clientTop - parent.scrollTop; + parentOffset.left += parent.clientLeft - parent.scrollLeft; } return { - width: element.prop('offsetWidth'), - height: element.prop('offsetHeight'), - top: elBCR.top - offsetParentBCR.top, - left: elBCR.left - offsetParentBCR.left + width: Math.round(angular.isNumber(elemOffset.width) ? elemOffset.width : elem.offsetWidth), + height: Math.round(angular.isNumber(elemOffset.height) ? elemOffset.height : elem.offsetHeight), + top: Math.round(elemOffset.top - parentOffset.top), + left: Math.round(elemOffset.left - parentOffset.left) }; }, /** * Provides read-only equivalent of jQuery's offset function: - * http://api.jquery.com/offset/ + * http://api.jquery.com/offset/ - distance to viewport. Does + * not account for borders, margins, or padding on the body + * element. + * + * @param {element} elem - The element to calculate the offset on. + * + * @returns {object} An object with the following properties: + * + * **width**: the width of the element + * **height**: the height of the element + * **top**: distance to top edge of viewport + * **right**: distance to bottom edge of viewport + * */ - offset: function (element) { - var boundingClientRect = element[0].getBoundingClientRect(); + offset: function(elem) { + elem = this.getRawNode(elem); + + var elemBCR = elem.getBoundingClientRect(); return { - width: element.prop('offsetWidth'), - height: element.prop('offsetHeight'), - top: boundingClientRect.top + ($window.pageYOffset || $document[0].body.scrollTop), - left: boundingClientRect.left + ($window.pageXOffset || $document[0].body.scrollLeft) + width: Math.round(angular.isNumber(elemBCR.width) ? elemBCR.width : elem.offsetWidth), + height: Math.round(angular.isNumber(elemBCR.height) ? elemBCR.height : elem.offsetHeight), + top: Math.round(elemBCR.top + ($window.pageYOffset || $document[0].documentElement.scrollTop)), + left: Math.round(elemBCR.left + ($window.pageXOffset || $document[0].documentElement.scrollLeft)) }; }, /** - * Provides the coordinates of the mouse + * Provides offset distance to the closest scrollable ancestor + * or viewport. Accounts for border and scrollbar width. + * + * Right and bottom dimensions represent the distance to the + * respective edge of the viewport element. If the element + * edge extends beyond the viewport, a negative value will be + * reported. + * + * @param {element} elem - The element to get the viewport offset for. + * @param {boolean=} [useDocument=false] - Should the viewport be the document element instead + * of the first scrollable element, default is false. + * @param {boolean=} [includePadding=true] - Should the padding on the offset parent element + * be accounted for, default is true. + * + * @returns {object} An object with the following properties: + * + * **top**: distance to the top content edge of viewport element + * **bottom**: distance to the bottom content edge of viewport element + * **left**: distance to the left content edge of viewport element + * **right**: distance to the right content edge of viewport element + * */ - mouse: function () { - return {x: mouseX, y: mouseY}; + viewportOffset: function(elem, useDocument, includePadding) { + elem = this.getRawNode(elem); + includePadding = includePadding !== false ? true : false; + + var elemBCR = elem.getBoundingClientRect(); + var offsetBCR = {top: 0, left: 0, bottom: 0, right: 0}; + + var offsetParent = useDocument ? $document[0].documentElement : this.scrollParent(elem); + var offsetParentBCR = offsetParent.getBoundingClientRect(); + + offsetBCR.top = offsetParentBCR.top + offsetParent.clientTop; + offsetBCR.left = offsetParentBCR.left + offsetParent.clientLeft; + if (offsetParent === $document[0].documentElement) { + offsetBCR.top += $window.pageYOffset; + offsetBCR.left += $window.pageXOffset; + } + offsetBCR.bottom = offsetBCR.top + offsetParent.clientHeight; + offsetBCR.right = offsetBCR.left + offsetParent.clientWidth; + + if (includePadding) { + var offsetParentStyle = $window.getComputedStyle(offsetParent); + offsetBCR.top += this.parseStyle(offsetParentStyle.paddingTop); + offsetBCR.bottom -= this.parseStyle(offsetParentStyle.paddingBottom); + offsetBCR.left += this.parseStyle(offsetParentStyle.paddingLeft); + offsetBCR.right -= this.parseStyle(offsetParentStyle.paddingRight); + } + + return { + top: Math.round(elemBCR.top - offsetBCR.top), + bottom: Math.round(offsetBCR.bottom - elemBCR.bottom), + left: Math.round(elemBCR.left - offsetBCR.left), + right: Math.round(offsetBCR.right - elemBCR.right) + }; + }, + + /** + * Provides an array of placement values parsed from a placement string. + * Along with the 'auto' indicator, supported placement strings are: + * + * top: element on top, horizontally centered on host element. + * top-left: element on top, left edge aligned with host element left edge. + * top-right: element on top, lerightft edge aligned with host element right edge. + * bottom: element on bottom, horizontally centered on host element. + * bottom-left: element on bottom, left edge aligned with host element left edge. + * bottom-right: element on bottom, right edge aligned with host element right edge. + * left: element on left, vertically centered on host element. + * left-top: element on left, top edge aligned with host element top edge. + * left-bottom: element on left, bottom edge aligned with host element bottom edge. + * right: element on right, vertically centered on host element. + * right-top: element on right, top edge aligned with host element top edge. + * right-bottom: element on right, bottom edge aligned with host element bottom edge. + * + * A placement string with an 'auto' indicator is expected to be + * space separated from the placement, i.e: 'auto bottom-left' If + * the primary and secondary placement values do not match 'top, + * bottom, left, right' then 'top' will be the primary placement and + * 'center' will be the secondary placement. If 'auto' is passed, true + * will be returned as the 3rd value of the array. + * + * @param {string} placement - The placement string to parse. + * + * @returns {array} An array with the following values + * + * **[0]**: The primary placement. + * **[1]**: The secondary placement. + * **[2]**: If auto is passed: true, else undefined. + * + */ + parsePlacement: function(placement) { + var autoPlace = PLACEMENT_REGEX.auto.test(placement); + if (autoPlace) { + placement = placement.replace(PLACEMENT_REGEX.auto, ''); + } + + placement = placement.split('-'); + + placement[0] = placement[0] || 'top'; + if (!PLACEMENT_REGEX.primary.test(placement[0])) { + placement[0] = 'top'; + } + + placement[1] = placement[1] || 'center'; + if (!PLACEMENT_REGEX.secondary.test(placement[1])) { + placement[1] = 'center'; + } + + if (autoPlace) { + placement[2] = true; + } else { + placement[2] = false; + } + + return placement; + }, + + /** + * Provides coordinates for an element to be positioned relative to + * another element. Passing 'auto' as part of the placement parameter + * will enable smart placement - where the element fits. i.e: + * 'auto left-top' will check to see if there is enough space to the left + * of the hostElem to fit the targetElem, if not place right (same for secondary + * top placement). Available space is calculated using the viewportOffset + * function. + * + * @param {element} hostElem - The element to position against. + * @param {element} targetElem - The element to position. + * @param {string=} [placement=top] - The placement for the targetElem, + * default is 'top'. 'center' is assumed as secondary placement for + * 'top', 'left', 'right', and 'bottom' placements. Available placements are: + * + * top + * top-right + * top-left + * bottom + * bottom-left + * bottom-right + * left + * left-top + * left-bottom + * right + * right-top + * right-bottom + * + * @param {boolean=} [appendToBody=false] - Should the top and left values returned + * be calculated from the body element, default is false. + * + * @returns {object} An object with the following properties: + * + * **top**: Value for targetElem top. + * **left**: Value for targetElem left. + * **placement**: The resolved placement. + * + */ + positionElements: function(hostElem, targetElem, placement, appendToBody) { + hostElem = this.getRawNode(hostElem); + targetElem = this.getRawNode(targetElem); + + // need to read from prop to support tests. + var targetWidth = angular.isDefined(targetElem.offsetWidth) ? targetElem.offsetWidth : targetElem.prop('offsetWidth'); + var targetHeight = angular.isDefined(targetElem.offsetHeight) ? targetElem.offsetHeight : targetElem.prop('offsetHeight'); + + placement = this.parsePlacement(placement); + + var hostElemPos = appendToBody ? this.offset(hostElem) : this.position(hostElem); + var targetElemPos = {top: 0, left: 0, placement: ''}; + + if (placement[2]) { + var viewportOffset = this.viewportOffset(hostElem, appendToBody); + + var targetElemStyle = $window.getComputedStyle(targetElem); + var adjustedSize = { + width: targetWidth + Math.round(Math.abs(this.parseStyle(targetElemStyle.marginLeft) + this.parseStyle(targetElemStyle.marginRight))), + height: targetHeight + Math.round(Math.abs(this.parseStyle(targetElemStyle.marginTop) + this.parseStyle(targetElemStyle.marginBottom))) + }; + + placement[0] = placement[0] === 'top' && adjustedSize.height > viewportOffset.top && adjustedSize.height <= viewportOffset.bottom ? 'bottom' : + placement[0] === 'bottom' && adjustedSize.height > viewportOffset.bottom && adjustedSize.height <= viewportOffset.top ? 'top' : + placement[0] === 'left' && adjustedSize.width > viewportOffset.left && adjustedSize.width <= viewportOffset.right ? 'right' : + placement[0] === 'right' && adjustedSize.width > viewportOffset.right && adjustedSize.width <= viewportOffset.left ? 'left' : + placement[0]; + + placement[1] = placement[1] === 'top' && adjustedSize.height - hostElemPos.height > viewportOffset.bottom && adjustedSize.height - hostElemPos.height <= viewportOffset.top ? 'bottom' : + placement[1] === 'bottom' && adjustedSize.height - hostElemPos.height > viewportOffset.top && adjustedSize.height - hostElemPos.height <= viewportOffset.bottom ? 'top' : + placement[1] === 'left' && adjustedSize.width - hostElemPos.width > viewportOffset.right && adjustedSize.width - hostElemPos.width <= viewportOffset.left ? 'right' : + placement[1] === 'right' && adjustedSize.width - hostElemPos.width > viewportOffset.left && adjustedSize.width - hostElemPos.width <= viewportOffset.right ? 'left' : + placement[1]; + + if (placement[1] === 'center') { + if (PLACEMENT_REGEX.vertical.test(placement[0])) { + var xOverflow = hostElemPos.width / 2 - targetWidth / 2; + if (viewportOffset.left + xOverflow < 0 && adjustedSize.width - hostElemPos.width <= viewportOffset.right) { + placement[1] = 'left'; + } else if (viewportOffset.right + xOverflow < 0 && adjustedSize.width - hostElemPos.width <= viewportOffset.left) { + placement[1] = 'right'; + } + } else { + var yOverflow = hostElemPos.height / 2 - adjustedSize.height / 2; + if (viewportOffset.top + yOverflow < 0 && adjustedSize.height - hostElemPos.height <= viewportOffset.bottom) { + placement[1] = 'top'; + } else if (viewportOffset.bottom + yOverflow < 0 && adjustedSize.height - hostElemPos.height <= viewportOffset.top) { + placement[1] = 'bottom'; + } + } + } + } + + switch (placement[0]) { + case 'top': + targetElemPos.top = hostElemPos.top - targetHeight; + break; + case 'bottom': + targetElemPos.top = hostElemPos.top + hostElemPos.height; + break; + case 'left': + targetElemPos.left = hostElemPos.left - targetWidth; + break; + case 'right': + targetElemPos.left = hostElemPos.left + hostElemPos.width; + break; + } + + switch (placement[1]) { + case 'top': + targetElemPos.top = hostElemPos.top; + break; + case 'bottom': + targetElemPos.top = hostElemPos.top + hostElemPos.height - targetHeight; + break; + case 'left': + targetElemPos.left = hostElemPos.left; + break; + case 'right': + targetElemPos.left = hostElemPos.left + hostElemPos.width - targetWidth; + break; + case 'center': + if (PLACEMENT_REGEX.vertical.test(placement[0])) { + targetElemPos.left = hostElemPos.left + hostElemPos.width / 2 - targetWidth / 2; + } else { + targetElemPos.top = hostElemPos.top + hostElemPos.height / 2 - targetHeight / 2; + } + break; + } + + targetElemPos.top = Math.round(targetElemPos.top); + targetElemPos.left = Math.round(targetElemPos.left); + targetElemPos.placement = placement[1] === 'center' ? placement[0] : placement[0] + '-' + placement[1]; + + return targetElemPos; + }, + + /** + * Provides a way to adjust the top positioning after first + * render to correctly align element to top after content + * rendering causes resized element height + * + * @param {array} placementClasses - The array of strings of classes + * element should have. + * @param {object} containerPosition - The object with container + * position information + * @param {number} initialHeight - The initial height for the elem. + * @param {number} currentHeight - The current height for the elem. + */ + adjustTop: function(placementClasses, containerPosition, initialHeight, currentHeight) { + if (placementClasses.indexOf('top') !== -1 && initialHeight !== currentHeight) { + return { + top: containerPosition.top - currentHeight + 'px' + }; + } + }, + + /** + * Provides a way for positioning tooltip & dropdown + * arrows when using placement options beyond the standard + * left, right, top, or bottom. + * + * @param {element} elem - The tooltip/dropdown element. + * @param {string} placement - The placement for the elem. + */ + positionArrow: function(elem, placement) { + elem = this.getRawNode(elem); + + var innerElem = elem.querySelector('.tooltip-inner, .popover-inner'); + if (!innerElem) { + return; + } + + var isTooltip = angular.element(innerElem).hasClass('tooltip-inner'); + + var arrowElem = isTooltip ? elem.querySelector('.tooltip-arrow') : elem.querySelector('.arrow'); + if (!arrowElem) { + return; + } + + var arrowCss = { + top: '', + bottom: '', + left: '', + right: '' + }; + + placement = this.parsePlacement(placement); + if (placement[1] === 'center') { + // no adjustment necessary - just reset styles + angular.element(arrowElem).css(arrowCss); + return; + } + + var borderProp = 'border-' + placement[0] + '-width'; + var borderWidth = $window.getComputedStyle(arrowElem)[borderProp]; + + var borderRadiusProp = 'border-'; + if (PLACEMENT_REGEX.vertical.test(placement[0])) { + borderRadiusProp += placement[0] + '-' + placement[1]; + } else { + borderRadiusProp += placement[1] + '-' + placement[0]; + } + borderRadiusProp += '-radius'; + var borderRadius = $window.getComputedStyle(isTooltip ? innerElem : elem)[borderRadiusProp]; + + switch (placement[0]) { + case 'top': + arrowCss.bottom = isTooltip ? '0' : '-' + borderWidth; + break; + case 'bottom': + arrowCss.top = isTooltip ? '0' : '-' + borderWidth; + break; + case 'left': + arrowCss.right = isTooltip ? '0' : '-' + borderWidth; + break; + case 'right': + arrowCss.left = isTooltip ? '0' : '-' + borderWidth; + break; + } + + arrowCss[placement[1]] = borderRadius; + + angular.element(arrowElem).css(arrowCss); } }; }]); diff --git a/src/position/test/position.spec.js b/src/position/test/position.spec.js new file mode 100644 index 0000000000..fcc0ea83a3 --- /dev/null +++ b/src/position/test/position.spec.js @@ -0,0 +1,557 @@ +describe('$uibPosition service', function () { + var TargetElMock = function(width, height) { + this.width = width; + this.height = height; + + this.prop = function(propName) { + return propName === 'offsetWidth' ? width : height; + }; + }; + + var $document; + var $uibPosition; + + beforeEach(module('ui.bootstrap.position')); + + beforeEach(inject(function(_$document_, _$uibPosition_) { + $document = _$document_; + $uibPosition = _$uibPosition_; + })); + + beforeEach(function () { + jasmine.addMatchers({ + toBePositionedAt: function(util, customEqualityTesters) { + return { + compare: function(actual, top, left) { + var result = { + pass: util.equals(actual.top, top, customEqualityTesters) && + util.equals(actual.left, left, customEqualityTesters) + }; + + if (result.pass) { + result.message = 'Expected "(' + actual.top + ', ' + actual.left + ')" not to be positioned at (' + top + ', ' + left + ')'; + } else { + result.message = 'Expected "(' + actual.top + ', ' + actual.left + ')" to be positioned at (' + top + ', ' + left + ')'; + } + + return result; + } + }; + } + }); + }); + + describe('rawnode', function() { + it('returns the raw DOM element from an angular element', function() { + var angularEl = angular.element(''); + var el = $uibPosition.getRawNode(angularEl); + expect(el.nodeName).toBe('DIV'); + }); + + it('returns the raw DOM element from a select element', function() { + var angularEl = angular.element('value'); + var el = $uibPosition.getRawNode(angularEl); + expect(el.nodeName).toBe('SELECT'); + }); + }); + + describe('offset', function() { + it('returns getBoundingClientRect by default', function() { + var el = angular.element('Foo'); + + /* getBoundingClientRect values will be based on the testing Chrome window + so that makes this tests very brittle if we don't mock */ + spyOn(el[0], 'getBoundingClientRect').and.returnValue({ + width: 100, + height: 100, + top: 2, + left: 2 + }); + $document.find('body').append(el); + + var offset = $uibPosition.offset(el); + + expect(offset).toEqual({ + width: 100, + height: 100, + top: 2, + left: 2 + }); + + el.remove(); + }); + }); + + describe('viewportOffset', function() { + var el; + + beforeEach(function() { + el = angular.element(''); + $document.find('body').append(el); + }); + + afterEach(function() { + el.remove(); + }); + + it('measures the offset', function() { + var vpOffset = $uibPosition.viewportOffset(document.getElementById('inner')); + expect(vpOffset).toEqual({ + top: 20, + bottom: 30, + left: 20, + right: 30 + }); + }); + + it('measures the offset without padding', function() { + var outerEl = document.getElementById('outer'); + outerEl.style.paddingTop = '0px'; + outerEl.style.paddingBottom = '0px'; + outerEl.style.paddingLeft = '0px'; + outerEl.style.paddingRight = '0px'; + + var vpOffset = $uibPosition.viewportOffset(document.getElementById('inner')); + expect(vpOffset).toEqual({ + top: 20, + bottom: 80, + left: 20, + right: 80 + }); + }); + + it('measures the offset with borders', function() { + var outerEl = document.getElementById('outer'); + outerEl.style.width = '220px'; + outerEl.style.height = '220px'; + outerEl.style.border = '10px solid black'; + + var vpOffset = $uibPosition.viewportOffset(document.getElementById('inner')); + expect(vpOffset).toEqual({ + top: 20, + bottom: 30, + left: 20, + right: 30 + }); + }); + + it('measures the offset excluding padding', function() { + var vpOffset = $uibPosition.viewportOffset(document.getElementById('inner'), false, false); + expect(vpOffset).toEqual({ + top: 45, + bottom: 55, + left: 45, + right: 55 + }); + }); + + it('measures the offset when scrolled', function() { + var innerEl = document.getElementById('inner'); + innerEl.style.width = '300px'; + innerEl.style.height = '300px'; + var outerEl = document.getElementById('outer'); + outerEl.scrollTop = 25; + outerEl.scrollLeft = 25; + + var vpOffset = $uibPosition.viewportOffset(document.getElementById('inner')); + expect(vpOffset.top).toEqual(-5); + expect(vpOffset.bottom).toBeGreaterThan(-180); + expect(vpOffset.left).toEqual(-5); + expect(vpOffset.right).toBeGreaterThan(-180); + + //brittle + // expect(vpOffset).toEqual({ + // top: -5, + // bottom: -162, + // left: -5, + // right: -162 + // }); + }); + + }); + + describe('position', function() { + var el; + + afterEach(function() { + el.remove(); + }); + + it('gets position with document as the relative parent', function() { + el = angular.element('Foo'); + + spyOn(el[0], 'getBoundingClientRect').and.returnValue({ + width: 100, + height: 100, + top: 2, + left: 2 + }); + + $document.find('body').append(el); + + var position = $uibPosition.position(el); + + expect(position).toEqual({ + width: 100, + height: 100, + top: 2, + left: 2 + }); + }); + + it('gets position with an element as the relative parent', function() { + el = angular.element('Foo'); + + $document.find('body').append(el); + + var outerEl = angular.element(document.getElementById('outer')); + var innerEl = angular.element(document.getElementById('inner')); + + spyOn(outerEl[0], 'getBoundingClientRect').and.returnValue({ + width: 100, + height: 100, + top: 2, + left: 2 + }); + spyOn(innerEl[0], 'getBoundingClientRect').and.returnValue({ + width: 20, + height: 20, + top: 5, + left: 5 + }); + + var position = $uibPosition.position(innerEl); + + expect(position).toEqual({ + width: 20, + height: 20, + top: 3, + left: 3 + }); + }); + }); + + describe('isScrollable', function() { + var el; + + afterEach(function() { + el.remove(); + }); + + it('should return true if the element is scrollable', function() { + el = angular.element(''); + $document.find('body').append(el); + expect($uibPosition.isScrollable(el)).toBe(true); + }); + + it('should return false if the element is scrollable', function() { + el = angular.element(''); + $document.find('body').append(el); + expect($uibPosition.isScrollable(el)).toBe(false); + }); + + }); + + describe('scrollParent', function() { + var el; + + afterEach(function() { + el.remove(); + }); + + it('gets the closest scrollable ancestor', function() { + el = angular.element('FooBar'); + + $document.find('body').css({overflow: 'auto'}).append(el); + + var outerEl = document.getElementById('outer'); + var innerEl = document.getElementById('inner'); + + var scrollParent = $uibPosition.scrollParent(innerEl); + expect(scrollParent).toEqual(outerEl); + }); + + it('gets the closest scrollable ancestor with overflow-x: scroll', function() { + el = angular.element('FooBar'); + + $document.find('body').css({overflow: 'auto'}).append(el); + + var outerEl = document.getElementById('outer'); + var innerEl = document.getElementById('inner'); + + var scrollParent = $uibPosition.scrollParent(innerEl); + expect(scrollParent).toEqual(outerEl); + }); + + it('gets the closest scrollable ancestor with overflow-y: hidden', function() { + el = angular.element('FooBar'); + + $document.find('body').css({overflow: 'auto'}).append(el); + + var outerEl = document.getElementById('outer'); + var innerEl = document.getElementById('inner'); + + var scrollParent = $uibPosition.scrollParent(innerEl, true); + expect(scrollParent).toEqual(outerEl); + }); + + it('gets the document element if no scrollable ancestor exists', function() { + el = angular.element('FooBar'); + + $document.find('body').css({overflow: ''}).append(el); + + var innerEl = document.getElementById('inner'); + + var scrollParent = $uibPosition.scrollParent(innerEl); + expect(scrollParent).toEqual($document[0].documentElement); + }); + + it('gets the closest scrollable ancestor after a positioned ancestor when positioned absolute', function() { + el = angular.element('FooBar'); + + $document.find('body').css({overflow: 'auto'}).append(el); + + var outerEl = document.getElementById('outer'); + var innerEl = document.getElementById('inner'); + + var scrollParent = $uibPosition.scrollParent(innerEl); + expect(scrollParent).toEqual(outerEl); + }); + }); + + describe('positionElements - append-to-body: false', function() { + var el; + + beforeEach(function() { + //mock position info normally queried from the DOM + $uibPosition.position = function() { + return { + width: 20, + height: 20, + top: 100, + left: 100 + }; + }; + }); + + it('should position element on top-center by default', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'other')).toBePositionedAt(90, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top')).toBePositionedAt(90, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-center')).toBePositionedAt(90, 105); + }); + + it('should position on top-left', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-left')).toBePositionedAt(90, 100); + }); + + it('should position on top-right', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-right')).toBePositionedAt(90, 110); + }); + + it('should position elements on bottom-center when "bottom" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom')).toBePositionedAt(120, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-center')).toBePositionedAt(120, 105); + }); + + it('should position elements on bottom-left', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-left')).toBePositionedAt(120, 100); + }); + + it('should position elements on bottom-right', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-right')).toBePositionedAt(120, 110); + }); + + it('should position elements on left-center when "left" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left')).toBePositionedAt(105, 90); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-center')).toBePositionedAt(105, 90); + }); + + it('should position elements on left-top when "left-top" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-top')).toBePositionedAt(100, 90); + }); + + it('should position elements on left-bottom when "left-bottom" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-bottom')).toBePositionedAt(110, 90); + }); + + it('should position elements on right-center when "right" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right')).toBePositionedAt(105, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-center')).toBePositionedAt(105, 120); + }); + + it('should position elements on right-top when "right-top" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-top')).toBePositionedAt(100, 120); + }); + + it('should position elements on right-top when "right-top" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-bottom')).toBePositionedAt(110, 120); + }); + }); + + describe('positionElements - append-to-body: true', function() { + beforeEach(function() { + //mock offset info normally queried from the DOM + $uibPosition.offset = function() { + return { + width: 20, + height: 20, + top: 100, + left: 100 + }; + }; + }); + + it('should position element on top-center by default', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'other', true)).toBePositionedAt(90, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top', true)).toBePositionedAt(90, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-center', true)).toBePositionedAt(90, 105); + }); + + it('should position on top-left', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-left', true)).toBePositionedAt(90, 100); + }); + + it('should position on top-right', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'top-right', true)).toBePositionedAt(90, 110); + }); + + it('should position elements on bottom-center when "bottom" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom', true)).toBePositionedAt(120, 105); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-center', true)).toBePositionedAt(120, 105); + }); + + it('should position elements on bottom-left', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-left', true)).toBePositionedAt(120, 100); + }); + + it('should position elements on bottom-right', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'bottom-right', true)).toBePositionedAt(120, 110); + }); + + it('should position elements on left-center when "left" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left', true)).toBePositionedAt(105, 90); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-center', true)).toBePositionedAt(105, 90); + }); + + it('should position elements on left-top when "left-top" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-top', true)).toBePositionedAt(100, 90); + }); + + it('should position elements on left-bottom when "left-bottom" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'left-bottom', true)).toBePositionedAt(110, 90); + }); + + it('should position elements on right-center when "right" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right', true)).toBePositionedAt(105, 120); + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-center', true)).toBePositionedAt(105, 120); + }); + + it('should position elements on right-top when "right-top" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-top', true)).toBePositionedAt(100, 120); + }); + + it('should position elements on right-bottom when "right-bottom" specified', function() { + expect($uibPosition.positionElements({}, new TargetElMock(10, 10), 'right-bottom', true)).toBePositionedAt(110, 120); + }); + }); + + describe('smart positioning', function() { + var viewportOffset, el; + + beforeEach(function() { + el = angular.element(''); + $document.find('body').append(el); + + //mock position info normally queried from the DOM + $uibPosition.position = function() { + return { + width: 40, + height: 40, + top: 100, + left: 100 + }; + }; + + viewportOffset = { + width: 10, + height: 10, + top: 10, + bottom: 10, + left: 10, + right: 10 + }; + + $uibPosition.viewportOffset = function() { + return viewportOffset; + }; + }); + + afterEach(function() { + el.remove(); + }); + + // tests primary top -> bottom + // tests secondary left -> right + it('should position element on bottom-right when top-left does not fit', function() { + viewportOffset.bottom = 20; + viewportOffset.left = 20; + el.css({ width: '60px', height: '20px' }); + expect($uibPosition.positionElements({}, el, 'auto top-left')).toBePositionedAt(140, 80); + }); + + // tests primary bottom -> top + // tests secondary right -> left + it('should position element on top-left when bottom-right does not fit', function() { + viewportOffset.top = 20; + viewportOffset.right = 20; + el.css({ width: '60px', height: '20px' }); + expect($uibPosition.positionElements({}, el, 'auto bottom-right')).toBePositionedAt(80, 100); + }); + + // tests primary left -> right + // tests secondary top -> bottom + it('should position element on right-bottom when left-top does not fit', function() { + viewportOffset.top = 20; + viewportOffset.right = 20; + el.css({ width: '20px', height: '60px' }); + expect($uibPosition.positionElements({}, el, 'auto left-top')).toBePositionedAt(80, 140); + }); + + // tests primary right -> left + // tests secondary bottom -> top + it('should position element on left-top when right-bottom does not fit', function() { + viewportOffset.bottom = 20; + viewportOffset.left = 20; + el.css({ width: '20px', height: '60px' }); + expect($uibPosition.positionElements({}, el, 'auto right-bottom')).toBePositionedAt(100, 80); + }); + + // tests vertical center -> top + it('should position element on left-top when left-center does not fit vetically', function() { + viewportOffset.bottom = 100; + el.css({ width: '20px', height: '120px' }); + expect($uibPosition.positionElements({}, el, 'auto left')).toBePositionedAt(100, 80); + }); + + // tests vertical center -> bottom + it('should position element on left-bottom when left-center does not fit vertically', function() { + viewportOffset.top = 100; + el.css({ width: '20px', height: '120px' }); + expect($uibPosition.positionElements({}, el, 'auto left')).toBePositionedAt(20, 80); + }); + + // tests horizontal center -> left + it('should position element on top-left when top-center does not fit horizontally', function() { + viewportOffset.right = 100; + el.css({ width: '120px', height: '20px' }); + expect($uibPosition.positionElements({}, el, 'auto top')).toBePositionedAt(80, 100); + }); + + // tests horizontal center -> right + it('should position element on top-right when top-center does not fit horizontally', function() { + viewportOffset.left = 100; + el.css({ width: '120px', height: '20px' }); + expect($uibPosition.positionElements({}, el, 'auto top')).toBePositionedAt(80, 20); + }); + }); +}); diff --git a/src/position/test/test.html b/src/position/test/test.html index b21948d697..3ce65b8a57 100644 --- a/src/position/test/test.html +++ b/src/position/test/test.html @@ -2,7 +2,7 @@ - + + - Select a tab by setting active binding to true: - - Select second tab - Select third tab - Enable / Disable third tab + Select a tab by setting active binding to true: + + Select second tab + Select third tab + + + Enable / Disable third tab + - - Static content - + + Static content + {{tab.content}} - - - - Select me for alert! - + + + + Alert! + I've got an HTML heading, and a select callback. Pretty cool! - - + + + + + + + Vertical content 1 + Vertical content 2 + + + + + + Justified content + Short Labeled Justified content + Long Labeled Justified content + + + + + Tabbed pills with CSS classes + + Tab 1 content + Tab 2 content + - - Vertical content 1 - Vertical content 2 - + Tabs using nested forms: + + + + + + Name + + + + + + Some Tab Content + + + More Tab Content + + + + Model: + {{ model | json }} + Nested Form: + {{ outerForm.nestedForm | json }} diff --git a/src/tabs/docs/demo.js b/src/tabs/docs/demo.js index da92adab20..d5e1f58b09 100644 --- a/src/tabs/docs/demo.js +++ b/src/tabs/docs/demo.js @@ -1,14 +1,16 @@ -var TabsDemoCtrl = function ($scope) { +angular.module('ui.bootstrap.demo').controller('TabsDemoCtrl', function ($scope, $window) { $scope.tabs = [ - { title:"Dynamic Title 1", content:"Dynamic content 1" }, - { title:"Dynamic Title 2", content:"Dynamic content 2", disabled: true } + { title:'Dynamic Title 1', content:'Dynamic content 1' }, + { title:'Dynamic Title 2', content:'Dynamic content 2', disabled: true } ]; $scope.alertMe = function() { setTimeout(function() { - alert("You've selected the alert tab!"); + $window.alert('You\'ve selected the alert tab!'); }); }; - $scope.navType = 'pills'; -}; + $scope.model = { + name: 'Tabs' + }; +}); diff --git a/src/tabs/docs/readme.md b/src/tabs/docs/readme.md index 15d77c606e..169c4dcd7f 100644 --- a/src/tabs/docs/readme.md +++ b/src/tabs/docs/readme.md @@ -1,31 +1,64 @@ AngularJS version of the tabs directive. -### Settings ### +### uib-tabset settings -#### `` #### +* `active` + + _(Default: `Index of first tab`)_ - + Active index of tab. Setting this to an existing tab index will make that tab active. - * `vertical` - _(Defaults: false)_ : - Whether tabs appear vertically stacked. +* `justified` + $ + _(Default: `false`)_ - + Whether tabs fill the container and have a consistent width. - * `type` - _(Defaults: 'tabs')_ : - Navigation type. Possible values are 'tabs' and 'pills'. + * `template-url` + _(Default: `uib/template/tabs/tabset.html`)_ - + A URL representing the location of a template to use for the main component. -#### `` #### +* `type` + _(Defaults: `tabs`)_ - + Navigation type. Possible values are 'tabs' and 'pills'. - * `heading` or `` - : - Heading text or HTML markup. +* `vertical` + $ + _(Default: `false`)_ - + Whether tabs appear vertically stacked. - * `active` - _(Defaults: false)_ : - Whether tab is currently selected. +### uib-tab settings - * `disabled` - _(Defaults: false)_ : - Whether tab is clickable and can be activated. +* `classes` + $ - + An optional string of space-separated CSS classes. - * `select()` - _(Defaults: null)_ : - An optional expression called when tab is activated. \ No newline at end of file +* `deselect()` + $ - + An optional expression called when tab is deactivated. Supports `$event` and `$selectedIndex` in template for expression. You may call `$event.preventDefault()` in this event handler to prevent a tab change from occurring. The `$selectedIndex` can be used to determine which tab was attempted to be opened. + +* `disable` + $ + + _(Default: `false`)_ - + Whether tab is clickable and can be activated. + +* `heading` - + Heading text. + +* `index` - + Tab index. Must be unique number or string. + +* `select()` + $ - + An optional expression called when tab is activated. Supports $event in template for expression. + +* `template-url` + _(Default: `uib/template/tabs/tab.html`)_ - + A URL representing the location of a template to use for the tab heading. + +### Tabset heading + +Instead of the `heading` attribute on the `uib-tabset`, you can use an `uib-tab-heading` element inside a tabset that will be used as the tabset's header. There you can use HTML as well. + +### Known issues + +To use clickable elements within the tab, you have override the tab template to use div elements instead of anchor elements, and replicate the desired styles from Bootstrap's CSS. This is due to browsers interpreting anchor elements as the target of any click event, which triggers routing when certain elements such as buttons are nested inside the anchor element. diff --git a/src/tabs/index.js b/src/tabs/index.js new file mode 100644 index 0000000000..b511806126 --- /dev/null +++ b/src/tabs/index.js @@ -0,0 +1,9 @@ +require('../../template/tabs/tab.html.js'); +require('../../template/tabs/tabset.html.js'); +require('./tabs'); + +var MODULE_NAME = 'ui.bootstrap.module.tabs'; + +angular.module(MODULE_NAME, ['ui.bootstrap.tabs', 'uib/template/tabs/tab.html', 'uib/template/tabs/tabset.html']); + +module.exports = MODULE_NAME; diff --git a/src/tabs/tabs.js b/src/tabs/tabs.js index ab7f0b4263..2e8f015c43 100644 --- a/src/tabs/tabs.js +++ b/src/tabs/tabs.js @@ -1,191 +1,136 @@ - -/** - * @ngdoc overview - * @name ui.bootstrap.tabs - * - * @description - * AngularJS version of the tabs directive. - */ - angular.module('ui.bootstrap.tabs', []) -.directive('tabs', function() { - return function() { - throw new Error("The `tabs` directive is deprecated, please migrate to `tabset`. Instructions can be found at http://github.com/angular-ui/bootstrap/tree/master/CHANGELOG.md"); +.controller('UibTabsetController', ['$scope', function ($scope) { + var ctrl = this, + oldIndex; + ctrl.tabs = []; + + ctrl.select = function(index, evt) { + if (!destroyed) { + var previousIndex = findTabIndex(oldIndex); + var previousSelected = ctrl.tabs[previousIndex]; + if (previousSelected) { + previousSelected.tab.onDeselect({ + $event: evt, + $selectedIndex: index + }); + if (evt && evt.isDefaultPrevented()) { + return; + } + previousSelected.tab.active = false; + } + + var selected = ctrl.tabs[index]; + if (selected) { + selected.tab.onSelect({ + $event: evt + }); + selected.tab.active = true; + ctrl.active = selected.index; + oldIndex = selected.index; + } else if (!selected && angular.isDefined(oldIndex)) { + ctrl.active = null; + oldIndex = null; + } + } }; -}) -.controller('TabsetController', ['$scope', '$element', -function TabsetCtrl($scope, $element) { + ctrl.addTab = function addTab(tab) { + ctrl.tabs.push({ + tab: tab, + index: tab.index + }); + ctrl.tabs.sort(function(t1, t2) { + if (t1.index > t2.index) { + return 1; + } - var ctrl = this, - tabs = ctrl.tabs = $scope.tabs = []; + if (t1.index < t2.index) { + return -1; + } - ctrl.select = function(tab) { - angular.forEach(tabs, function(tab) { - tab.active = false; + return 0; }); - tab.active = true; - }; - ctrl.addTab = function addTab(tab) { - tabs.push(tab); - if (tabs.length === 1 || tab.active) { - ctrl.select(tab); + if (tab.index === ctrl.active || !angular.isDefined(ctrl.active) && ctrl.tabs.length === 1) { + var newActiveIndex = findTabIndex(tab.index); + ctrl.select(newActiveIndex); } }; ctrl.removeTab = function removeTab(tab) { - var index = tabs.indexOf(tab); - //Select a new tab if the tab to be removed is selected - if (tab.active && tabs.length > 1) { - //If this is the last tab, select the previous tab. else, the next tab. - var newActiveIndex = index == tabs.length - 1 ? index - 1 : index + 1; - ctrl.select(tabs[newActiveIndex]); + var index; + for (var i = 0; i < ctrl.tabs.length; i++) { + if (ctrl.tabs[i].tab === tab) { + index = i; + break; + } + } + + if (ctrl.tabs[index].index === ctrl.active) { + var newActiveTabIndex = index === ctrl.tabs.length - 1 ? + index - 1 : index + 1 % ctrl.tabs.length; + ctrl.select(newActiveTabIndex); } - tabs.splice(index, 1); + + ctrl.tabs.splice(index, 1); }; + + $scope.$watch('tabset.active', function(val) { + if (angular.isDefined(val) && val !== oldIndex) { + ctrl.select(findTabIndex(val)); + } + }); + + var destroyed; + $scope.$on('$destroy', function() { + destroyed = true; + }); + + function findTabIndex(index) { + for (var i = 0; i < ctrl.tabs.length; i++) { + if (ctrl.tabs[i].index === index) { + return i; + } + } + } }]) -/** - * @ngdoc directive - * @name ui.bootstrap.tabs.directive:tabset - * @restrict EA - * - * @description - * Tabset is the outer container for the tabs directive - * - * @param {boolean=} vertical Whether or not to use vertical styling for the tabs. - * @param {string=} direction What direction the tabs should be rendered. Available: - * 'right', 'left', 'below'. - * - * @example - - - - First Content! - Second Content! - - - - First Vertical Content! - Second Vertical Content! - - - - */ -.directive('tabset', function() { +.directive('uibTabset', function() { return { - restrict: 'EA', transclude: true, replace: true, - require: '^tabset', scope: {}, - controller: 'TabsetController', - templateUrl: 'template/tabs/tabset.html', - compile: function(elm, attrs, transclude) { - return function(scope, element, attrs, tabsetCtrl) { - scope.vertical = angular.isDefined(attrs.vertical) ? scope.$eval(attrs.vertical) : false; - scope.type = angular.isDefined(attrs.type) ? scope.$parent.$eval(attrs.type) : 'tabs'; - scope.direction = angular.isDefined(attrs.direction) ? scope.$parent.$eval(attrs.direction) : 'top'; - scope.tabsAbove = (scope.direction != 'below'); - tabsetCtrl.$scope = scope; - tabsetCtrl.$transcludeFn = transclude; - }; + bindToController: { + active: '=?', + type: '@' + }, + controller: 'UibTabsetController', + controllerAs: 'tabset', + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/tabs/tabset.html'; + }, + link: function(scope, element, attrs) { + scope.vertical = angular.isDefined(attrs.vertical) ? + scope.$parent.$eval(attrs.vertical) : false; + scope.justified = angular.isDefined(attrs.justified) ? + scope.$parent.$eval(attrs.justified) : false; } }; }) -/** - * @ngdoc directive - * @name ui.bootstrap.tabs.directive:tab - * @restrict EA - * - * @param {string=} heading The visible heading, or title, of the tab. Set HTML headings with {@link ui.bootstrap.tabs.directive:tabHeading tabHeading}. - * @param {string=} select An expression to evaluate when the tab is selected. - * @param {boolean=} active A binding, telling whether or not this tab is selected. - * @param {boolean=} disabled A binding, telling whether or not this tab is disabled. - * - * @description - * Creates a tab with a heading and content. Must be placed within a {@link ui.bootstrap.tabs.directive:tabset tabset}. - * - * @example - - - - - Select item 1, using active binding - - - Enable/disable item 2, using disabled binding - - - - First Tab - - Alert me! - Second Tab, with alert callback and html heading! - - - {{item.content}} - - - - - - function TabsDemoCtrl($scope) { - $scope.items = [ - { title:"Dynamic Title 1", content:"Dynamic Item 0" }, - { title:"Dynamic Title 2", content:"Dynamic Item 1", disabled: true } - ]; - - $scope.alertMe = function() { - setTimeout(function() { - alert("You've selected the alert tab!"); - }); - }; - }; - - - */ - -/** - * @ngdoc directive - * @name ui.bootstrap.tabs.directive:tabHeading - * @restrict EA - * - * @description - * Creates an HTML heading for a {@link ui.bootstrap.tabs.directive:tab tab}. Must be placed as a child of a tab element. - * - * @example - - - - - HTML in my titles?! - And some content, too! - - - Icon heading?!? - That's right. - - - - - */ -.directive('tab', ['$parse', '$http', '$templateCache', '$compile', -function($parse, $http, $templateCache, $compile) { +.directive('uibTab', ['$parse', function($parse) { return { - require: '^tabset', - restrict: 'EA', + require: '^uibTabset', replace: true, - templateUrl: 'template/tabs/tab.html', + templateUrl: function(element, attrs) { + return attrs.templateUrl || 'uib/template/tabs/tab.html'; + }, transclude: true, scope: { heading: '@', + index: '=?', + classes: '@?', onSelect: '&select', //This callback is called in contentHeadingTransclude //once it inserts the tab's content into the dom onDeselect: '&deselect' @@ -193,65 +138,58 @@ function($parse, $http, $templateCache, $compile) { controller: function() { //Empty controller so other directives can require being 'under' a tab }, - compile: function(elm, attrs, transclude) { - return function postLink(scope, elm, attrs, tabsetCtrl) { - var getActive, setActive; - if (attrs.active) { - getActive = $parse(attrs.active); - setActive = getActive.assign; - scope.$parent.$watch(getActive, function updateActive(value) { - scope.active = !!value; - }); - scope.active = getActive(scope.$parent); - } else { - setActive = getActive = angular.noop; - } - - scope.$watch('active', function(active) { - setActive(scope.$parent, active); - if (active) { - tabsetCtrl.select(scope); - scope.onSelect(); - } else { - scope.onDeselect(); - } + controllerAs: 'tab', + link: function(scope, elm, attrs, tabsetCtrl, transclude) { + scope.disabled = false; + if (attrs.disable) { + scope.$parent.$watch($parse(attrs.disable), function(value) { + scope.disabled = !! value; }); + } - scope.disabled = false; - if ( attrs.disabled ) { - scope.$parent.$watch($parse(attrs.disabled), function(value) { - scope.disabled = !! value; - }); + if (angular.isUndefined(attrs.index)) { + if (tabsetCtrl.tabs && tabsetCtrl.tabs.length) { + scope.index = Math.max.apply(null, tabsetCtrl.tabs.map(function(t) { return t.index; })) + 1; + } else { + scope.index = 0; } + } + + if (angular.isUndefined(attrs.classes)) { + scope.classes = ''; + } - scope.select = function() { - if ( ! scope.disabled ) { - scope.active = true; + scope.select = function(evt) { + if (!scope.disabled) { + var index; + for (var i = 0; i < tabsetCtrl.tabs.length; i++) { + if (tabsetCtrl.tabs[i].tab === scope) { + index = i; + break; + } } - }; - tabsetCtrl.addTab(scope); - scope.$on('$destroy', function() { - tabsetCtrl.removeTab(scope); - }); - if (scope.active) { - setActive(scope.$parent, true); + tabsetCtrl.select(index, evt); } + }; + tabsetCtrl.addTab(scope); + scope.$on('$destroy', function() { + tabsetCtrl.removeTab(scope); + }); - //We need to transclude later, once the content container is ready. - //when this link happens, we're inside a tab heading. - scope.$transcludeFn = transclude; - }; + //We need to transclude later, once the content container is ready. + //when this link happens, we're inside a tab heading. + scope.$transcludeFn = transclude; } }; }]) -.directive('tabHeadingTransclude', [function() { +.directive('uibTabHeadingTransclude', function() { return { restrict: 'A', - require: '^tab', - link: function(scope, elm, attrs, tabCtrl) { + require: '^uibTab', + link: function(scope, elm) { scope.$watch('headingElement', function updateHeadingElement(heading) { if (heading) { elm.html(''); @@ -260,14 +198,14 @@ function($parse, $http, $templateCache, $compile) { }); } }; -}]) +}) -.directive('tabContentTransclude', ['$compile', '$parse', function($compile, $parse) { +.directive('uibTabContentTransclude', function() { return { restrict: 'A', - require: '^tabset', + require: '^uibTabset', link: function(scope, elm, attrs) { - var tab = scope.$eval(attrs.tabContentTransclude); + var tab = scope.$eval(attrs.uibTabContentTransclude).tab; //Now our tab is ready to be transcluded: both the tab heading area //and the tab content area are loaded. Transclude 'em both. @@ -283,34 +221,16 @@ function($parse, $http, $templateCache, $compile) { }); } }; + function isTabHeading(node) { - return node.tagName && ( - node.hasAttribute('tab-heading') || - node.hasAttribute('data-tab-heading') || - node.tagName.toLowerCase() === 'tab-heading' || - node.tagName.toLowerCase() === 'data-tab-heading' + return node.tagName && ( + node.hasAttribute('uib-tab-heading') || + node.hasAttribute('data-uib-tab-heading') || + node.hasAttribute('x-uib-tab-heading') || + node.tagName.toLowerCase() === 'uib-tab-heading' || + node.tagName.toLowerCase() === 'data-uib-tab-heading' || + node.tagName.toLowerCase() === 'x-uib-tab-heading' || + node.tagName.toLowerCase() === 'uib:tab-heading' ); } -}]) - -.directive('tabsetTitles', ['$http', function($http) { - return { - restrict: 'A', - require: '^tabset', - templateUrl: 'template/tabs/tabset-titles.html', - replace: true, - link: function(scope, elm, attrs, tabsetCtrl) { - if (!scope.$eval(attrs.tabsetTitles)) { - elm.remove(); - } else { - //now that tabs location has been decided, transclude the tab titles in - tabsetCtrl.$transcludeFn(tabsetCtrl.$scope.$parent, function(node) { - elm.append(node); - }); - } - } - }; -}]) - -; - +}); diff --git a/src/tabs/test/tabs.spec.js b/src/tabs/test/tabs.spec.js new file mode 100644 index 0000000000..76bfd7c6db --- /dev/null +++ b/src/tabs/test/tabs.spec.js @@ -0,0 +1,1058 @@ +describe('tabs', function() { + var elm, scope; + + beforeEach(module('ui.bootstrap.tabs')); + beforeEach(module('uib/template/tabs/tabset.html')); + beforeEach(module('uib/template/tabs/tab.html')); + + function titles() { + return elm.find('ul.nav-tabs li'); + } + function contents() { + return elm.find('div.tab-content div.tab-pane'); + } + + function expectTitles(titlesArray) { + var t = titles(); + expect(t.length).toEqual(titlesArray.length); + for (var i = 0; i < t.length; i++) { + expect(t.eq(i).text().trim()).toEqual(titlesArray[i]); + } + } + + function expectContents(contentsArray) { + var c = contents(); + expect(c.length).toEqual(contentsArray.length); + for (var i = 0; i < c.length; i++) { + expect(c.eq(i).text().trim()).toEqual(contentsArray[i]); + } + } + + describe('basics', function() { + beforeEach(inject(function($compile, $rootScope) { + scope = $rootScope.$new(); + scope.first = '1'; + scope.second = '2'; + scope.third = '3'; + scope.active = 1; + scope.firstClass = 'first-class'; + scope.secondClass = 'second-class-1 second-class-2'; + scope.selectFirst = jasmine.createSpy(); + scope.selectSecond = jasmine.createSpy(); + scope.deselectFirst = jasmine.createSpy(); + scope.deselectSecond = jasmine.createSpy(); + scope.deselectThird = function($event) { + $event.preventDefault(); + }; + elm = $compile([ + '', + ' ', + ' first content is {{first}}', + ' ', + ' ', + ' Second Tab {{second}}', + ' second content is {{second}}', + ' ', + ' ', + ' Second Tab {{third}}', + ' third content is {{third}}', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + return elm; + })); + + it('should pass class and other attributes on to tab template', function() { + expect(elm).toHaveClass('hello'); + expect(elm.attr('data-pizza')).toBe('pepperoni'); + //Ensure that we have bootstrap 4 link class so things are future proofed. + var link = $(elm.find('a')[0]); + expect(link).toHaveClass('nav-link'); + }); + + it('should create clickable titles', function() { + var t = titles(); + expect(t.length).toBe(3); + expect(t.find('> a').eq(0).text()).toBe('First Tab 1'); + //It should put the uib-tab-heading element into the 'a' title + expect(t.find('> a').eq(1).children().is('uib-tab-heading')).toBe(true); + expect(t.find('> a').eq(1).children().html()).toBe('Second Tab 2'); + }); + + it('should bind tabs content and set first tab active', function() { + expectContents(['first content is 1', 'second content is 2', 'third content is 3']); + expect(titles().eq(0)).toHaveClass('active'); + expect(titles().eq(1)).not.toHaveClass('active'); + expect(scope.active).toBe(1); + }); + + it('should set optional classes on each tab', function() { + expect(titles().eq(0)).toHaveClass(scope.firstClass); + + var secondClassArr = scope.secondClass.split(' '); + secondClassArr.forEach(function(clazz) { + expect(titles().eq(1)).toHaveClass(clazz); + }); + }); + + it('should change active on click', function() { + titles().eq(1).find('> a').click(); + expect(contents().eq(1)).toHaveClass('active'); + expect(titles().eq(0)).not.toHaveClass('active'); + expect(titles().eq(1)).toHaveClass('active'); + expect(scope.active).toBe(2); + }); + + it('should call select callback on select', function() { + expect(scope.selectFirst.calls.count()).toBe(1); + titles().eq(1).find('> a').click(); + expect(scope.selectSecond).toHaveBeenCalled(); + expect(scope.selectSecond.calls.argsFor(0)[0].target).toBe(titles().eq(1).find('> a')[0]); + titles().eq(0).find('> a').click(); + expect(scope.selectFirst).toHaveBeenCalled(); + expect(scope.selectFirst.calls.argsFor(1)[0].target).toBe(titles().eq(0).find('> a')[0]); + }); + + it('should call deselect callback on deselect', function() { + titles().eq(1).find('> a').click(); + expect(scope.deselectFirst).toHaveBeenCalled(); + expect(scope.deselectFirst.calls.argsFor(0)[0].target).toBe(titles().eq(1).find('> a')[0]); + expect(scope.deselectFirst.calls.argsFor(0)[1]).toBe(1); + titles().eq(0).find('> a').click(); + expect(scope.deselectSecond).toHaveBeenCalled(); + expect(scope.deselectSecond.calls.argsFor(0)[0].target).toBe(titles().eq(0).find('> a')[0]); + expect(scope.deselectSecond.calls.argsFor(0)[1]).toBe(0); + titles().eq(1).find('> a').click(); + expect(scope.deselectFirst.calls.count()).toBe(2); + expect(scope.deselectFirst.calls.argsFor(1)[0].target).toBe(titles().eq(1).find('> a')[0]); + expect(scope.deselectFirst.calls.argsFor(1)[1]).toBe(1); + }); + + it('should prevent tab deselection when $event.preventDefault() is called', function() { + spyOn(scope, 'deselectThird'); + titles().eq(2).find('> a').click(); + expect(scope.active).toBe(3); + titles().eq(1).find('> a').click(); + expect(scope.deselectThird).toHaveBeenCalled(); + expect(scope.active).not.toBe(1); + expect(scope.active).toBe(2); + }); + }); + + describe('basics with initial active tab', function() { + beforeEach(inject(function($compile, $rootScope) { + scope = $rootScope.$new(); + + function makeTab(index) { + return { + index: index, + select: jasmine.createSpy() + }; + } + scope.tabs = [ + makeTab(1), makeTab(3), makeTab(5), makeTab(7) + ]; + scope.active = 5; + elm = $compile([ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + })); + + function expectTabActive(activeTab) { + var _titles = titles(); + angular.forEach(scope.tabs, function(tab, i) { + if (activeTab === tab) { + expect(scope.active).toBe(tab.index); + //It should only call select ONCE for each select + expect(tab.select).toHaveBeenCalled(); + expect(_titles.eq(i)).toHaveClass('active'); + expect(contents().eq(i)).toHaveClass('active'); + } else { + expect(scope.active).not.toBe(tab.index); + expect(_titles.eq(i)).not.toHaveClass('active'); + } + }); + } + + it('should make tab titles and set active tab active', function() { + expect(titles().length).toBe(scope.tabs.length); + expectTabActive(scope.tabs[2]); + }); + }); + + describe('without active binding and index attributes', function() { + beforeEach(inject(function($compile, $rootScope) { + scope = $rootScope.$new(); + scope.first = '1'; + scope.second = '2'; + elm = $compile([ + '', + ' ', + ' first content is {{first}}', + ' ', + ' ', + ' second content is {{second}}', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + return elm; + })); + + it('should bind tabs content and set first tab active', function() { + expectContents(['first content is 1', 'second content is 2']); + expect(titles().eq(0)).toHaveClass('active'); + expect(titles().eq(1)).not.toHaveClass('active'); + expect(elm.controller('uibTabset').active).toBe(0); + }); + + it('should change active on click', function() { + titles().eq(1).find('> a').click(); + expect(contents().eq(1)).toHaveClass('active'); + expect(titles().eq(0)).not.toHaveClass('active'); + expect(titles().eq(1)).toHaveClass('active'); + expect(elm.controller('uibTabset').active).toBe(1); + }); + }); + + describe('index as strings', function() { + beforeEach(inject(function($compile, $rootScope) { + scope = $rootScope.$new(); + scope.first = 'one'; + scope.second = 'two'; + scope.active = 'two'; + elm = $compile([ + '', + ' ', + ' first content', + ' ', + ' ', + ' second content', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + return elm; + })); + + it('should set second tab active', function() { + expect(titles().eq(0)).not.toHaveClass('active'); + expect(titles().eq(1)).toHaveClass('active'); + expect(elm.controller('uibTabset').active).toBe('two'); + }); + + it('should change active on click', function() { + expect(titles().eq(0)).not.toHaveClass('active'); + titles().eq(0).find('> a').click(); + expect(titles().eq(0)).toHaveClass('active'); + expect(titles().eq(1)).not.toHaveClass('active'); + expect(elm.controller('uibTabset').active).toBe('one'); + }); + }); + + describe('tab callback order', function() { + var execOrder; + beforeEach(inject(function($compile, $rootScope) { + scope = $rootScope.$new(); + execOrder = []; + + scope.execute = function(id) { + execOrder.push(id); + }; + + elm = $compile([ + '', + ' ', + ' ', + ' ', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + return elm; + })); + + it('should call select for the first tab', function() { + expect(execOrder).toEqual([ 'select1' ]); + }); + + it('should call deselect, then select', function() { + execOrder = []; + + // Select second tab + titles().eq(1).find('> a').click(); + expect(execOrder).toEqual([ 'deselect1', 'select2' ]); + + execOrder = []; + + // Select again first tab + titles().eq(0).find('> a').click(); + expect(execOrder).toEqual([ 'deselect2', 'select1' ]); + }); + }); + + describe('custom template', function() { + var $compile, $templateCache; + beforeEach(inject(function($rootScope, _$compile_, _$templateCache_) { + scope = $rootScope; + $compile = _$compile_; + $templateCache = _$templateCache_; + })); + + it('should support custom templates', function() { + $templateCache.put('foo/bar.html', 'baz'); + + elm = $compile('')(scope); + scope.$digest(); + + expect(elm.html()).toBe('baz'); + }); + }); + + describe('uib-tab', function() { + var $compile, $templateCache; + + beforeEach(inject(function($rootScope, _$compile_, _$templateCache_) { + scope = $rootScope; + $compile = _$compile_; + $templateCache = _$templateCache_; + })); + + it('should expose the controller on the view', function() { + $templateCache.put('uib/template/tabs/tab.html', '{{tab.text}}'); + + elm = $compile('')(scope); + scope.$digest(); + + var tab = titles().eq(0); + var ctrl = tab.controller('uibTab'); + + expect(ctrl).toBeDefined(); + + ctrl.text = 'foo'; + scope.$digest(); + + expect(tab.text().trim()).toBe('foo'); + }); + + it('should support custom templates', function() { + $templateCache.put('foo/bar.html', 'baz'); + + elm = $compile('')(scope); + scope.$digest(); + + var tabTitle = titles().eq(0); + + expect(tabTitle.html()).toBe('baz'); + }); + }); + + describe('ng-repeat', function() { + var $compile, $rootScope; + beforeEach(inject(function(_$compile_, _$rootScope_) { + $compile = _$compile_; + $rootScope = _$rootScope_; + scope = $rootScope.$new(); + + scope.tabs = [ + makeTab(1), makeTab(3), makeTab(5), makeTab(7) + ]; + scope.active = 5; + elm = $compile([ + '', + ' ', + ' heading {{index}}', + ' content {{$index}}', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + })); + + function makeTab(index) { + return { + index: index, + select: jasmine.createSpy() + }; + } + + function titles() { + return elm.find('ul.nav-tabs li'); + } + function contents() { + return elm.find('div.tab-content div.tab-pane'); + } + + function expectTabActive(activeTab) { + var _titles = titles(); + angular.forEach(scope.tabs, function(tab, i) { + if (activeTab === tab) { + expect(scope.active).toBe(tab.index); + //It should only call select ONCE for each select + expect(tab.select).toHaveBeenCalled(); + expect(_titles.eq(i)).toHaveClass('active'); + expect(contents().eq(i).text().trim()).toBe('content ' + i); + expect(contents().eq(i)).toHaveClass('active'); + } else { + expect(scope.active).not.toBe(tab.index); + expect(_titles.eq(i)).not.toHaveClass('active'); + } + }); + } + + it('should make tab titles and set active tab active', function() { + expect(titles().length).toBe(scope.tabs.length); + expectTabActive(scope.tabs[2]); + }); + + it('should switch active when clicking', function() { + titles().eq(3).find('> a').click(); + expectTabActive(scope.tabs[3]); + }); + + it('should switch active when changing active index', function() { + scope.$apply('active = 5'); + expectTabActive(scope.tabs[2]); + }); + + it('should deselect all when no tabs are active', function() { + scope.active = 101; + scope.$apply(); + expectTabActive(null); + expect(contents().filter('.active').length).toBe(0); + + scope.active = 5; + scope.$apply(); + expectTabActive(scope.tabs[2]); + }); + + it('should not select twice', function() { + elm.remove(); + elm = null; + scope = $rootScope.$new(); + + scope.tabs = [ + makeTab(2), makeTab(3), makeTab(5), makeTab(8) + ]; + scope.active = 13; + scope.select = jasmine.createSpy(); + elm = $compile([ + '', + ' ', + ' heading {{index}}', + ' content {{$index}}', + ' ', + ' ', + ' heading foo', + ' content foo', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + + expect(scope.select.calls.count()).toBe(1); + }); + }); + + describe('advanced uib-tab-heading element', function() { + beforeEach(inject(function($compile, $rootScope, $sce) { + scope = $rootScope.$new(); + scope.myHtml = $sce.trustAsHtml('hello, there!'); + scope.value = true; + elm = $compile([ + '', + ' ', + ' ', + ' ', + ' 1', + ' 2', + ' 3', + '' + ].join('\n'))(scope); + scope.$apply(); + })); + + function heading() { + return elm.find('ul li > a').children(); + } + + it('should create a heading bound to myHtml', function() { + expect(heading().eq(0).html()).toBe('hello, there!'); + }); + + it('should hide and show the heading depending on value', function() { + expect(heading().eq(0)).not.toBeHidden(); + scope.$apply('value = false'); + expect(heading().eq(0)).toBeHidden(); + scope.$apply('value = true'); + expect(heading().eq(0)).not.toBeHidden(); + }); + + it('should have a uib-tab-heading no matter what syntax was used', function() { + expect(heading().eq(1).text()).toBe('1'); + expect(heading().eq(2).text()).toBe('2'); + expect(heading().eq(3).text()).toBe('3'); + }); + + }); + + //Tests that http://git.io/lG6I9Q is fixed + describe('tab ordering', function() { + beforeEach(inject(function($compile, $rootScope) { + scope = $rootScope.$new(); + scope.tabs = [ + { title:'Title 1', available:true }, + { title:'Title 2', available:true }, + { title:'Title 3', available:true } + ]; + elm = $compile([ + '', + ' ', + ' div that makes troubles', + ' First Static', + ' another div that may do evil', + ' some content', + ' ', + ' Mid Static', + ' a text node', + ' ', + ' yet another span that may do evil', + ' some content', + ' a text node', + ' yet another span that may do evil', + ' ', + ' Last Static', + ' a text node', + ' yet another span that may do evil', + ' ', + '' + ].join('\n'))(scope); + + scope.tabIsAvailable = function(tab) { + return tab.available; + }; + })); + + it('should preserve correct ordering', function() { + function titles() { + return elm.find('ul.nav-tabs li > a'); + } + scope.$apply(); + expect(titles().length).toBe(9); + scope.$apply('tabs[1].available=false'); + scope.$digest(); + expect(titles().length).toBe(7); + scope.$apply('tabs[0].available=false'); + scope.$digest(); + expect(titles().length).toBe(5); + scope.$apply('tabs[2].available=false'); + scope.$digest(); + expect(titles().length).toBe(3); + scope.$apply('tabs[0].available=true'); + scope.$digest(); + expect(titles().length).toBe(5); + scope.$apply('tabs[1].available=true'); + scope.$apply('tabs[2].available=true'); + scope.$digest(); + expect(titles().length).toBe(9); + expect(titles().eq(0).text().trim()).toBe('first'); + expect(titles().eq(1).text().trim()).toBe('Title 1'); + expect(titles().eq(2).text().trim()).toBe('Title 2'); + expect(titles().eq(3).text().trim()).toBe('Title 3'); + expect(titles().eq(4).text().trim()).toBe('mid'); + expect(titles().eq(5).text().trim()).toBe('Second Title 1'); + expect(titles().eq(6).text().trim()).toBe('Second Title 2'); + expect(titles().eq(7).text().trim()).toBe('Second Title 3'); + expect(titles().eq(8).text().trim()).toBe('last'); + }); + }); + + describe('uib-tabset controller', function() { + function mockTab(index) { + return { + index: index, + onSelect : angular.noop, + onDeselect : angular.noop + }; + } + + var ctrl; + beforeEach(inject(function($controller, $rootScope) { + scope = $rootScope; + //instantiate the controller stand-alone, without the directive + ctrl = $controller('UibTabsetController', {$scope: scope}); + })); + + describe('select', function() { + it('should mark given tab selected', function() { + ctrl.tabs = [ + { + tab: mockTab(0), + index: 0 + } + ]; + + ctrl.select(0); + expect(ctrl.active).toBe(0); + }); + + + it('should deselect other tabs', function() { + var tab1 = mockTab(1), tab2 = mockTab(2), tab3 = mockTab(3); + + ctrl.addTab(tab1); + ctrl.addTab(tab2); + ctrl.addTab(tab3); + + ctrl.select(0); + expect(ctrl.active).toBe(1); + + ctrl.select(1); + expect(ctrl.active).toBe(2); + + ctrl.select(2); + expect(ctrl.active).toBe(3); + }); + }); + + + describe('addTab', function() { + it('should append tab', function() { + var tab1 = mockTab(1), tab2 = mockTab(2); + + expect(ctrl.tabs).toEqual([]); + + ctrl.addTab(tab1); + expect(ctrl.tabs).toEqual([ + { + tab: tab1, + index: 1 + } + ]); + + ctrl.addTab(tab2); + expect(ctrl.tabs).toEqual([ + { + tab: tab1, + index: 1 + }, + { + tab: tab2, + index: 2 + } + ]); + }); + + it('should select the first one', function() { + var tab1 = mockTab(1), tab2 = mockTab(2); + + ctrl.addTab(tab1); + expect(ctrl.active).toBe(1); + + ctrl.addTab(tab2); + expect(ctrl.active).toBe(1); + }); + + it('should not select first active === false tab as selected', function() { + var tab = mockTab(0); + ctrl.active = 1; + + ctrl.addTab(tab); + expect(ctrl.active).toBe(1); + }); + + it('should retain active state when adding tab of different index', function() { + var tab1 = mockTab(1), tab2 = mockTab(2); + ctrl.active = 2; + ctrl.addTab(tab1); + expect(ctrl.active).toBe(2); + + ctrl.addTab(tab2); + expect(ctrl.active).toBe(2); + }); + }); + }); + + describe('remove', function() { + it('should remove title tabs when elements are destroyed and change selection', inject(function($controller, $compile, $rootScope) { + scope = $rootScope.$new(); + elm = $compile('Hellocontent {{i}}')(scope); + scope.$apply(); + + expectTitles(['1']); + expectContents(['Hello']); + + scope.$apply('list = [1,2,3]'); + expectTitles(['1', 'tab 1', 'tab 2', 'tab 3']); + expectContents(['Hello', 'content 1', 'content 2', 'content 3']); + + // Select last tab + titles().find('> a').eq(3).click(); + expect(contents().eq(3)).toHaveClass('active'); + expect(titles().eq(3)).toHaveClass('active'); + + // Remove last tab + scope.$apply('list = [1,2]'); + expectTitles(['1', 'tab 1', 'tab 2']); + expectContents(['Hello', 'content 1', 'content 2']); + + // "tab 2" is now selected + expect(titles().eq(2)).toHaveClass('active'); + expect(contents().eq(2)).toHaveClass('active'); + + // Select 2nd tab ("tab 1") + titles().find('> a').eq(1).click(); + expect(titles().eq(1)).toHaveClass('active'); + expect(contents().eq(1)).toHaveClass('active'); + + // Remove 2nd tab + scope.$apply('list = [2]'); + expectTitles(['1', 'tab 2']); + expectContents(['Hello', 'content 2']); + + // New 2nd tab is now selected + expect(titles().eq(1)).toHaveClass('active'); + expect(contents().eq(1)).toHaveClass('active'); + })); + + it('should use updated index in tab', inject(function($controller, $compile, $rootScope) { + scope = $rootScope.$new(); + elm = $compile('Hellocontent {{i}}')(scope); + scope.$apply(); + + scope.$apply('list = [1,2,3]'); + expectTitles(['1', 'tab 1', 'tab 2', 'tab 3']); + expectContents(['Hello', 'content 1', 'content 2', 'content 3']); + + // Remove middle "tab 2" tab + scope.$apply('list = [1,3]'); + expectTitles(['1', 'tab 1', 'tab 3']); + expectContents(['Hello', 'content 1', 'content 3']); + + // Remove last "tab 3" tab + scope.$apply('list = [1]'); + expectTitles(['1', 'tab 1']); + expectContents(['Hello', 'content 1']); + + // Select first tab + titles().find('> a').eq(0).click(); + expect(titles().eq(0)).toHaveClass('active'); + expect(contents().eq(0)).toHaveClass('active'); + })); + + it('should not select tabs when being destroyed', inject(function($controller, $compile, $rootScope) { + var selectList = [], + deselectList = [], + getTab = function(index) { + return { + index: index, + select: function() { + selectList.push('select'); + }, + deselect: function() { + deselectList.push('deselect'); + } + }; + }; + + scope = $rootScope.$new(); + scope.tabs = [ + getTab(0), + getTab(1) + ]; + scope.active = 1; + elm = $compile([ + '', + ' ', + ' heading {{index}}', + ' content {{$index}}', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + + // The first tab is selected the during the initial $digest. + expect(selectList.length).toEqual(1); + + // Destroy the tabs - we should not trigger selection/deselection any more. + scope.$destroy(); + expect(selectList.length).toEqual(1); + expect(deselectList.length).toEqual(0); + })); + }); + + describe('disable', function() { + beforeEach(inject(function($compile, $rootScope) { + scope = $rootScope.$new(); + + function makeTab(disable, index) { + return { + index: index, + select: jasmine.createSpy(), + disable: disable + }; + } + scope.tabs = [ + makeTab(false, 0), makeTab(true, 1), makeTab(false, 2), makeTab(true, 3) + ]; + scope.active = 1; + elm = $compile([ + '', + ' ', + ' heading {{index}}', + ' content {{$index}}', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + })); + + function expectTabActive(activeTab) { + var _titles = titles(); + angular.forEach(scope.tabs, function(tab, i) { + if (activeTab === tab) { + expect(scope.active).toBe(tab.index); + expect(tab.select.calls.count()).toBe(tab.disable ? 0 : 1); + expect(_titles.eq(i)).toHaveClass('active'); + expect(contents().eq(i).text().trim()).toBe('content ' + i); + expect(contents().eq(i)).toHaveClass('active'); + } else { + expect(scope.active).not.toBe(tab.index); + expect(_titles.eq(i)).not.toHaveClass('active'); + } + }); + } + + it('should not switch active when clicking on title', function() { + titles().eq(2).find('> a').click(); + expectTabActive(scope.tabs[2]); + + titles().eq(3).find('> a').click(); + expectTabActive(scope.tabs[2]); + }); + + it('should toggle between states', function() { + expect(titles().eq(3)).toHaveClass('disabled'); + scope.$apply('tabs[3].disable = false'); + expect(titles().eq(3)).not.toHaveClass('disabled'); + + expect(titles().eq(2)).not.toHaveClass('disabled'); + scope.$apply('tabs[2].disable = true'); + expect(titles().eq(2)).toHaveClass('disabled'); + }); + }); + + describe('vertical', function() { + beforeEach(inject(function($compile, $rootScope) { + scope = $rootScope.$new(); + scope.vertical = true; + elm = $compile('')(scope); + scope.$apply(); + })); + + it('to stack tabs', function() { + expect(elm.find('ul.nav-tabs')).toHaveClass('nav-stacked'); + }); + }); + + describe('justified', function() { + beforeEach(inject(function($compile, $rootScope) { + scope = $rootScope.$new(); + scope.justified = true; + elm = $compile('')(scope); + scope.$apply(); + })); + + it('to justify tabs', function() { + expect(elm.find('ul.nav-tabs')).toHaveClass('nav-justified'); + }); + }); + + describe('type', function() { + beforeEach(inject(function($compile, $rootScope) { + scope = $rootScope.$new(); + scope.navType = 'pills'; + + elm = $compile('')(scope); + scope.$apply(); + })); + + it('to show pills', function() { + expect(elm.find('ul')).toHaveClass('nav-pills'); + expect(elm.find('ul')).not.toHaveClass('nav-tabs'); + }); + }); + + //https://github.com/angular-ui/bootstrap/issues/524 + describe('child compilation', function() { + var elm; + beforeEach(inject(function($compile, $rootScope) { + elm = $compile('')($rootScope.$new()); + $rootScope.$apply(); + })); + + it('should hookup the tab\'s children to the tab with $compile', function() { + var tabChild = $('.tab-pane', elm).children().first(); + expect(tabChild.inheritedData('$uibTabsetController')).toBeTruthy(); + }); + }); + + //https://github.com/angular-ui/bootstrap/issues/631 + describe('ng-options in content', function() { + var elm; + it('should render correct amount of options', inject(function($compile, $rootScope) { + var scope = $rootScope.$new(); + elm = $compile('')(scope); + scope.$apply(); + + var select = elm.find('select'); + scope.$apply(); + expect(select.children().length).toBe(4); + })); + }); + + //https://github.com/angular-ui/bootstrap/issues/599 + describe('ng-repeat in content', function() { + var elm; + it('should render ng-repeat', inject(function($compile, $rootScope) { + var scope = $rootScope.$new(); + scope.tabs = [ + {title:'a', array:[1,2,3]}, + {title:'b', array:[2,3,4]}, + {title:'c', array:[3,4,5]} + ]; + elm = $compile('' + + '' + + '{{$index}}' + + '{{a}},' + + '' + + '')(scope); + scope.$apply(); + + var contents = elm.find('.tab-pane'); + expect(contents.eq(0).text().trim()).toEqual('1,2,3,'); + expect(contents.eq(1).text().trim()).toEqual('2,3,4,'); + expect(contents.eq(2).text().trim()).toEqual('3,4,5,'); + })); + }); + + //https://github.com/angular-ui/bootstrap/issues/783 + describe('nested tabs', function() { + var elm; + it('should render without errors', inject(function($compile, $rootScope) { + var scope = $rootScope.$new(); + elm = $compile([ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + + // 1 outside tabset, 2 nested tabsets + expect(elm.find('.tabbable').length).toEqual(3); + })); + + it('should render with the correct scopes', inject(function($compile, $rootScope) { + var scope = $rootScope.$new(); + scope.tab1Text = 'abc'; + scope.tab1aText = '123'; + scope.tab1aHead = '123'; + scope.tab2aaText = '456'; + elm = $compile([ + '', + ' ', + ' ', + ' ', + ' ', + ' {{ tab1aText }}', + ' ', + ' ', + ' {{ tab1Text }}', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' {{ tab2aaText }}', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + + var outsideTabset = elm.find('.tabbable').eq(0); + var nestedTabset = outsideTabset.find('.tabbable'); + + expect(elm.find('.tabbable').length).toEqual(4); + expect(outsideTabset.find('.tab-pane').eq(0).find('.tab-1').text().trim()).toEqual(scope.tab1Text); + expect(nestedTabset.find('.tab-pane').eq(0).text().trim()).toEqual(scope.tab1aText); + expect(nestedTabset.find('ul.nav-tabs li').eq(0).text().trim()).toEqual(scope.tab1aHead); + expect(nestedTabset.eq(2).find('.tab-pane').eq(0).find('.tab-2aa').text().trim()).toEqual(scope.tab2aaText); + })); + + it('ng-repeat works with nested tabs', inject(function($compile, $rootScope) { + var scope = $rootScope.$new(); + scope.tabs = [ + { + tabs: [ + { + content: 'tab1a' + }, + { + content: 'tab2a' + } + ], + content: 'tab1' + } + ]; + elm = $compile([ + '', + ' ', + ' ', + ' ', + ' ', + ' {{ innerTab.content }}', + ' ', + ' ', + ' {{ tab.content }}', + ' ', + ' ', + '' + ].join('\n'))(scope); + scope.$apply(); + + expect(elm.find('.inner-tab-content').eq(0).text().trim()).toEqual(scope.tabs[0].tabs[0].content); + expect(elm.find('.inner-tab-content').eq(1).text().trim()).toEqual(scope.tabs[0].tabs[1].content); + expect(elm.find('.outer-tab-content').eq(0).text().trim()).toEqual(scope.tabs[0].content); + })); + }); +}); diff --git a/src/tabs/test/tabsSpec.js b/src/tabs/test/tabsSpec.js deleted file mode 100644 index e8a9822fef..0000000000 --- a/src/tabs/test/tabsSpec.js +++ /dev/null @@ -1,599 +0,0 @@ -describe('tabs', function() { - beforeEach(module('ui.bootstrap.tabs', 'template/tabs/tabset.html', 'template/tabs/tab.html', 'template/tabs/tabset-titles.html')); - - var elm, scope; - function titles() { - return elm.find('ul.nav-tabs li'); - } - function contents() { - return elm.find('div.tab-content div.tab-pane'); - } - - function expectTitles(titlesArray) { - var t = titles(); - expect(t.length).toEqual(titlesArray.length); - for (var i=0; i', - ' ', - ' ', - ' first content is {{first}}', - ' ', - ' ', - ' Second Tab {{second}}', - ' second content is {{second}}', - ' ', - ' ', - '' - ].join('\n'))(scope); - scope.$apply(); - return elm; - })); - - it('should pass class and other attributes on to tab template', function() { - var tabbable = elm.find('.tabbable'); - expect(tabbable).toHaveClass('hello'); - expect(tabbable.attr('data-pizza')).toBe('pepperoni'); - }); - - it('should create clickable titles', function() { - var t = titles(); - expect(t.length).toBe(2); - expect(t.find('a').eq(0).text()).toBe('First Tab 1'); - //It should put the tab-heading element into the 'a' title - expect(t.find('a').eq(1).children().is('tab-heading')).toBe(true); - expect(t.find('a').eq(1).children().html()).toBe('Second Tab 2'); - }); - - it('should bind tabs content and set first tab active', function() { - expectContents(['first content is 1', 'second content is 2']); - expect(titles().eq(0)).toHaveClass('active'); - expect(titles().eq(1)).not.toHaveClass('active'); - expect(scope.actives.one).toBe(true); - expect(scope.actives.two).toBe(false); - }); - - it('should change active on click', function() { - titles().eq(1).find('a').click(); - expect(contents().eq(1)).toHaveClass('active'); - expect(titles().eq(0)).not.toHaveClass('active'); - expect(titles().eq(1)).toHaveClass('active'); - expect(scope.actives.one).toBe(false); - expect(scope.actives.two).toBe(true); - }); - - it('should call select callback on select', function() { - titles().eq(1).find('a').click(); - expect(scope.selectSecond).toHaveBeenCalled(); - titles().eq(0).find('a').click(); - expect(scope.selectFirst).toHaveBeenCalled(); - }); - - it('should call deselect callback on deselect', function() { - titles().eq(1).find('a').click(); - titles().eq(0).find('a').click(); - expect(scope.deselectSecond).toHaveBeenCalled(); - titles().eq(1).find('a').click(); - expect(scope.deselectFirst).toHaveBeenCalled(); - }); - - }); - - describe('ng-repeat', function() { - - beforeEach(inject(function($compile, $rootScope) { - scope = $rootScope.$new(); - - function makeTab(active) { - return { - active: !!active, - select: jasmine.createSpy() - }; - } - scope.tabs = [ - makeTab(), makeTab(), makeTab(true), makeTab() - ]; - elm = $compile([ - '', - ' ', - ' heading {{index}}', - ' content {{$index}}', - ' ', - '' - ].join('\n'))(scope); - scope.$apply(); - })); - - function titles() { - return elm.find('ul.nav-tabs li'); - } - function contents() { - return elm.find('div.tab-content div.tab-pane'); - } - - function expectTabActive(activeTab) { - var _titles = titles(); - angular.forEach(scope.tabs, function(tab, i) { - if (activeTab === tab) { - expect(tab.active).toBe(true); - //It should only call select ONCE for each select - expect(tab.select).toHaveBeenCalled(); - expect(_titles.eq(i)).toHaveClass('active'); - expect(contents().eq(i).text().trim()).toBe('content ' + i); - expect(contents().eq(i)).toHaveClass('active'); - } else { - expect(tab.active).toBe(false); - expect(_titles.eq(i)).not.toHaveClass('active'); - } - }); - } - - it('should make tab titles and set active tab active', function() { - expect(titles().length).toBe(scope.tabs.length); - expectTabActive(scope.tabs[2]); - }); - - it('should switch active when clicking', function() { - titles().eq(3).find('a').click(); - expectTabActive(scope.tabs[3]); - }); - - it('should switch active when setting active=true', function() { - scope.$apply('tabs[2].active = true'); - expectTabActive(scope.tabs[2]); - }); - - it('should deselect all when no tabs are active', function() { - angular.forEach(scope.tabs, function(t) { t.active = false; }); - scope.$apply(); - expectTabActive(null); - expect(contents().filter('.active').length).toBe(0); - - scope.tabs[2].active = true; - scope.$apply(); - expectTabActive(scope.tabs[2]); - }); - }); - - describe('advanced tab-heading element', function() { - beforeEach(inject(function($compile, $rootScope) { - scope = $rootScope.$new(); - scope.myHtml = "hello, there!"; - scope.value = true; - elm = $compile([ - '', - ' ', - ' ', - ' ', - ' ', - ' 1', - ' 2', - ' 3', - '' - ].join('\n'))(scope); - scope.$apply(); - })); - - function heading() { - return elm.find('ul li a').children(); - } - - it('should create a heading bound to myHtml', function() { - expect(heading().eq(0).html()).toBe("hello, there!"); - }); - - it('should hide and show the heading depending on value', function() { - expect(heading().eq(0).css('display')).not.toBe('none'); - scope.$apply('value = false'); - expect(heading().eq(0).css('display')).toBe('none'); - scope.$apply('value = true'); - expect(heading().eq(0).css('display')).not.toBe('none'); - }); - - it('should have a tab-heading no matter what syntax was used', function() { - expect(heading().eq(1).text()).toBe('1'); - expect(heading().eq(2).text()).toBe('2'); - expect(heading().eq(3).text()).toBe('3'); - }); - - }); - - //Tests that http://git.io/lG6I9Q is fixed - describe('tab ordering', function() { - - beforeEach(inject(function($compile, $rootScope) { - scope = $rootScope.$new(); - scope.tabs = [ - { title:"Title 1", available:true }, - { title:"Title 2", available:true }, - { title:"Title 3", available:true } - ]; - elm = $compile([ - '', - ' ', - ' div that makes troubles', - ' First Static', - ' another div that may do evil', - ' some content', - ' ', - ' Mid Static', - ' a text node', - ' ', - ' yet another span that may do evil', - ' some content', - ' a text node', - ' yet another span that may do evil', - ' ', - ' Last Static', - ' a text node', - ' yet another span that may do evil', - ' ', - '' - ].join('\n'))(scope); - - scope.tabIsAvailable = function(tab) { - return tab.available; - }; - })); - - it('should preserve correct ordering', function() { - function titles() { - return elm.find('ul.nav-tabs li a'); - } - scope.$apply(); - expect(titles().length).toBe(9); - scope.$apply('tabs[1].available=false'); - scope.$digest(); - expect(titles().length).toBe(7); - scope.$apply('tabs[0].available=false'); - scope.$digest(); - expect(titles().length).toBe(5); - scope.$apply('tabs[2].available=false'); - scope.$digest(); - expect(titles().length).toBe(3); - scope.$apply('tabs[0].available=true'); - scope.$digest(); - expect(titles().length).toBe(5); - scope.$apply('tabs[1].available=true'); - scope.$apply('tabs[2].available=true'); - scope.$digest(); - expect(titles().length).toBe(9); - expect(titles().eq(0).text().trim()).toBe("first"); - expect(titles().eq(1).text().trim()).toBe("Title 1"); - expect(titles().eq(2).text().trim()).toBe("Title 2"); - expect(titles().eq(3).text().trim()).toBe("Title 3"); - expect(titles().eq(4).text().trim()).toBe("mid"); - expect(titles().eq(5).text().trim()).toBe("Second Title 1"); - expect(titles().eq(6).text().trim()).toBe("Second Title 2"); - expect(titles().eq(7).text().trim()).toBe("Second Title 3"); - expect(titles().eq(8).text().trim()).toBe("last"); - }); - }); - - describe('tabset controller', function() { - function mockTab(isActive) { - return { active: !!isActive }; - } - - var ctrl; - beforeEach(inject(function($controller, $rootScope) { - scope = $rootScope; - //instantiate the controller stand-alone, without the directive - ctrl = $controller('TabsetController', {$scope: scope, $element: null}); - })); - - - describe('select', function() { - - it('should mark given tab selected', function() { - var tab = mockTab(); - - ctrl.select(tab); - expect(tab.active).toBe(true); - }); - - - it('should deselect other tabs', function() { - var tab1 = mockTab(), tab2 = mockTab(), tab3 = mockTab(); - - ctrl.addTab(tab1); - ctrl.addTab(tab2); - ctrl.addTab(tab3); - - ctrl.select(tab1); - expect(tab1.active).toBe(true); - expect(tab2.active).toBe(false); - expect(tab3.active).toBe(false); - - ctrl.select(tab2); - expect(tab1.active).toBe(false); - expect(tab2.active).toBe(true); - expect(tab3.active).toBe(false); - - ctrl.select(tab3); - expect(tab1.active).toBe(false); - expect(tab2.active).toBe(false); - expect(tab3.active).toBe(true); - }); - }); - - - describe('addTab', function() { - - it('should append tab', function() { - var tab1 = mockTab(), tab2 = mockTab(); - - expect(ctrl.tabs).toEqual([]); - - ctrl.addTab(tab1); - expect(ctrl.tabs).toEqual([tab1]); - - ctrl.addTab(tab2); - expect(ctrl.tabs).toEqual([tab1, tab2]); - }); - - - it('should select the first one', function() { - var tab1 = mockTab(), tab2 = mockTab(); - - ctrl.addTab(tab1); - expect(tab1.active).toBe(true); - - ctrl.addTab(tab2); - expect(tab1.active).toBe(true); - }); - - it('should select a tab added that\'s already active', function() { - var tab1 = mockTab(), tab2 = mockTab(true); - ctrl.addTab(tab1); - expect(tab1.active).toBe(true); - - ctrl.addTab(tab2); - expect(tab1.active).toBe(false); - expect(tab2.active).toBe(true); - }); - }); - }); - - describe('remove', function() { - - it('should remove title tabs when elements are destroyed and change selection', inject(function($controller, $compile, $rootScope) { - scope = $rootScope.$new(); - elm = $compile("Hellocontent {{i}}")(scope); - scope.$apply(); - - expectTitles(['1']); - expectContents(['Hello']); - - scope.$apply('list = [1,2,3]'); - expectTitles(['1', 'tab 1', 'tab 2', 'tab 3']); - expectContents(['Hello', 'content 1', 'content 2', 'content 3']); - - titles().find('a').eq(3).click(); - expect(contents().eq(3)).toHaveClass('active'); - expect(titles().eq(3)).toHaveClass('active'); - - scope.$apply('list = [1,2]'); - expectTitles(['1', 'tab 1', 'tab 2']); - expectContents(['Hello', 'content 1', 'content 2']); - - expect(titles().eq(2)).toHaveClass('active'); - expect(contents().eq(2)).toHaveClass('active'); - })); - }); - - describe('disabled', function() { - beforeEach(inject(function($compile, $rootScope) { - scope = $rootScope.$new(); - - function makeTab(disabled) { - return { - active: false, - select: jasmine.createSpy(), - disabled: disabled - }; - } - scope.tabs = [ - makeTab(false), makeTab(true), makeTab(false), makeTab(true) - ]; - elm = $compile([ - '', - ' ', - ' heading {{index}}', - ' content {{$index}}', - ' ', - '' - ].join('\n'))(scope); - scope.$apply(); - })); - - function expectTabActive(activeTab) { - var _titles = titles(); - angular.forEach(scope.tabs, function(tab, i) { - if (activeTab === tab) { - expect(tab.active).toBe(true); - expect(tab.select.callCount).toBe( (tab.disabled) ? 0 : 1 ); - expect(_titles.eq(i)).toHaveClass('active'); - expect(contents().eq(i).text().trim()).toBe('content ' + i); - expect(contents().eq(i)).toHaveClass('active'); - } else { - expect(tab.active).toBe(false); - expect(_titles.eq(i)).not.toHaveClass('active'); - } - }); - } - - it('should not switch active when clicking on title', function() { - titles().eq(2).find('a').click(); - expectTabActive(scope.tabs[2]); - - titles().eq(3).find('a').click(); - expectTabActive(scope.tabs[2]); - }); - - it('should toggle between states', function() { - expect(titles().eq(3)).toHaveClass('disabled'); - scope.$apply('tabs[3].disabled = false'); - expect(titles().eq(3)).not.toHaveClass('disabled'); - - expect(titles().eq(2)).not.toHaveClass('disabled'); - scope.$apply('tabs[2].disabled = true'); - expect(titles().eq(2)).toHaveClass('disabled'); - }); - }); - - describe('vertical', function() { - beforeEach(inject(function($compile, $rootScope) { - scope = $rootScope.$new(); - - elm = $compile('')(scope); - scope.$apply(); - })); - - it('to stack tabs', function() { - expect(elm.find('ul.nav-tabs')).toHaveClass('nav-stacked'); - }); - }); - - describe('type', function() { - beforeEach(inject(function($compile, $rootScope) { - scope = $rootScope.$new(); - scope.navType = 'pills'; - - elm = $compile('')(scope); - scope.$apply(); - })); - - it('to show pills', function() { - expect(elm.find('ul')).toHaveClass('nav-pills'); - expect(elm.find('ul')).not.toHaveClass('nav-tabs'); - }); - }); - - describe('direction', function() { - it('should not have `tab-left`, `tab-right` nor `tabs-below` classes if the direction is undefined', inject(function($compile, $rootScope) { - scope = $rootScope.$new(); - scope.direction = undefined; - - elm = $compile('')(scope); - scope.$apply(); - expect(elm).not.toHaveClass('tabs-left'); - expect(elm).not.toHaveClass('tabs-right'); - expect(elm).not.toHaveClass('tabs-below'); - expect(elm.find('.nav + .tab-content').length).toBe(1); - })); - - it('should only have the `tab-left` direction class if the direction is "left"', inject(function($compile, $rootScope) { - scope = $rootScope.$new(); - scope.direction = 'left'; - - elm = $compile('')(scope); - scope.$apply(); - expect(elm).toHaveClass('tabs-left'); - expect(elm).not.toHaveClass('tabs-right'); - expect(elm).not.toHaveClass('tabs-below'); - expect(elm.find('.nav + .tab-content').length).toBe(1); - })); - - it('should only have the `tab-right direction class if the direction is "right"', inject(function($compile, $rootScope) { - scope = $rootScope.$new(); - scope.direction = 'right'; - - elm = $compile('')(scope); - scope.$apply(); - expect(elm).not.toHaveClass('tabs-left'); - expect(elm).toHaveClass('tabs-right'); - expect(elm).not.toHaveClass('tabs-below'); - expect(elm.find('.nav + .tab-content').length).toBe(1); - })); - - it('should only have the `tab-below direction class if the direction is "below"', inject(function($compile, $rootScope) { - scope = $rootScope.$new(); - scope.direction = 'below'; - - elm = $compile('')(scope); - scope.$apply(); - expect(elm).not.toHaveClass('tabs-left'); - expect(elm).not.toHaveClass('tabs-right'); - expect(elm).toHaveClass('tabs-below'); - expect(elm.find('.tab-content + .nav').length).toBe(1); - })); - }); - - //https://github.com/angular-ui/bootstrap/issues/524 - describe('child compilation', function() { - - var elm; - beforeEach(inject(function($compile, $rootScope) { - elm = $compile('')($rootScope.$new()); - $rootScope.$apply(); - })); - - it('should hookup the tab\'s children to the tab with $compile', function() { - var tabChild = $('.tab-pane', elm).children().first(); - expect(tabChild.inheritedData('$tabsetController')).toBeTruthy(); - }); - }); - - //https://github.com/angular-ui/bootstrap/issues/631 - describe('ng-options in content', function() { - var elm; - it('should render correct amount of options', inject(function($compile, $rootScope) { - var scope = $rootScope.$new(); - elm = $compile('')(scope); - scope.$apply(); - - var select = elm.find('select'); - scope.$apply(); - expect(select.children().length).toBe(4); - })); - }); - - //https://github.com/angular-ui/bootstrap/issues/599 - describe('ng-repeat in content', function() { - var elm; - it('should render ng-repeat', inject(function($compile, $rootScope) { - var scope = $rootScope.$new(); - scope.tabs = [ - {title:'a', array:[1,2,3]}, - {title:'b', array:[2,3,4]}, - {title:'c', array:[3,4,5]} - ]; - elm = $compile('' + - '' + - '{{$index}}' + - '{{a}},' + - '' + - '')(scope); - scope.$apply(); - - var contents = elm.find('.tab-pane'); - expect(contents.eq(0).text().trim()).toEqual('1,2,3,'); - expect(contents.eq(1).text().trim()).toEqual('2,3,4,'); - expect(contents.eq(2).text().trim()).toEqual('3,4,5,'); - })); - }); -}); diff --git a/src/timepicker/docs/demo.html b/src/timepicker/docs/demo.html index 9fdf7452cb..fc610a5a6e 100644 --- a/src/timepicker/docs/demo.html +++ b/src/timepicker/docs/demo.html @@ -1,14 +1,24 @@ - - + + + + Time is: {{mytime | date:'shortTime' }} + + + + Hours step is: + + + + Minutes step is: + + - Time is: {{mytime | date:'shortTime' }} + - Hours step is: - Minutes step is: + 12H / 24H + Set to 14:00 + Clear - 12H / 24H - Set to 14:00 - Clear - \ No newline at end of file + diff --git a/src/timepicker/docs/demo.js b/src/timepicker/docs/demo.js index 478545b5be..8c9ade2bae 100644 --- a/src/timepicker/docs/demo.js +++ b/src/timepicker/docs/demo.js @@ -1,4 +1,4 @@ -var TimepickerDemoCtrl = function ($scope) { +angular.module('ui.bootstrap.demo').controller('TimepickerDemoCtrl', function ($scope, $log) { $scope.mytime = new Date(); $scope.hstep = 1; @@ -22,10 +22,10 @@ var TimepickerDemoCtrl = function ($scope) { }; $scope.changed = function () { - console.log('Time changed to: ' + $scope.mytime); + $log.log('Time changed to: ' + $scope.mytime); }; $scope.clear = function() { $scope.mytime = null; }; -}; +}); diff --git a/src/timepicker/docs/readme.md b/src/timepicker/docs/readme.md index 3ed75a6abb..e0cb7b02dd 100644 --- a/src/timepicker/docs/readme.md +++ b/src/timepicker/docs/readme.md @@ -1,33 +1,111 @@ A lightweight & configurable timepicker directive. -### Settings ### +### uib-timepicker settings -All settings can be provided as attributes in the `` or globally configured through the `timepickerConfig`. +* `arrowkeys` + $ + C + _(Default: `true`)_ - + Whether user can use up/down arrow keys inside the hours & minutes input to increase or decrease its values. - * `ng-model` - : - The Date object that provides the time state. +* `hour-step` + $ + C + + _(Default: `1`)_ - + Number of hours to increase or decrease when using a button. - * `hour-step` - _(Defaults: 1)_ : - Number of hours to increase or decrease when using a button. +* `max` + $ + + _(Default: `undefined`)_ - + Maximum time a user can select. - * `minute-step` - _(Defaults: 1)_ : - Number of minutes to increase or decrease when using a button. +* `meridians` + $ + C + _(Default: `null`)_ - + Meridian labels based on locale. To override you must supply an array like `['AM', 'PM']`. - * `show-meridian` - _(Defaults: true)_ : - Whether to display 12H or 24H mode. +* `min` + $ + + _(Default: `undefined`)_ - + Minimum time a user can select - * `meridians` - _(Defaults: ['AM', 'PM'])_ : - Meridian labels +* `minute-step` + $ + C + + _(Default: `1`)_ - + Number of minutes to increase or decrease when using a button. - * `readonly-input` - _(Defaults: false)_ : - Whether user can type inside the hours & minutes input. +* `mousewheel` + $ + C + _(Default: `true`)_ - + Whether user can scroll inside the hours & minutes input to increase or decrease its values. - * `mousewheel` - _(Defaults: true)_ : - Whether user can scroll inside the hours & minutes input to increase or decrease it's values. +* `ng-disabled` + $ + + _(Default: `false`)_ - + Whether or not to disable the component. + +* `ng-model` + $ + - + Date object that provides the time state. + +* `pad-hours` + $ + _(Default: true)_ - + Whether the hours column is padded with a 0. + +* `readonly-input` + $ + C + _(Default: `false`)_ - + Whether user can type inside the hours & minutes input. + +* `second-step` + $ + C + + _(Default: `1`)_ - + Number of seconds to increase or decrease when using a button. + +* `show-meridian` + $ + C + + _(Default: `true`)_ - + Whether to display 12H or 24H mode. + +* `show-seconds` + $ + C + + _(Default: `false`)_ - + Show seconds input. + +* `show-spinners` + $ + C + _(Default: `true`)_ - + Show spinner arrows above and below the inputs. + +* `tabindex` + _(Defaults: `0`)_ - + Sets tabindex for each control in the timepicker. + +* `template-url` + C + _(Defaults: `uib/template/timepicker/timepicker.html`)_ - + Add the ability to override the template used on the component. + +**Notes** + +This component makes no claims of absolutely supporting the preservation of dates in all cases, and it is highly recommended that model tracking of dates is encapsulated in a different object. This component should not be used with the same model as the datepicker. This is due to edge cases with situations such as Daylight Savings timezone changes which require a modification of the date in order to prevent an impossible to increment or decrement situation. See [#5485](https://github.com/angular-ui/bootstrap/issues/5485) for details. + +If the model value is updated (i.e. via `Date.prototype.setDate`), you must update the model value by breaking the reference by `modelValue = new Date(modelValue)` in order to have the timepicker update. diff --git a/src/timepicker/index-nocss.js b/src/timepicker/index-nocss.js new file mode 100644 index 0000000000..772ca923f4 --- /dev/null +++ b/src/timepicker/index-nocss.js @@ -0,0 +1,8 @@ +require('../../template/timepicker/timepicker.html.js'); +require('./timepicker'); + +var MODULE_NAME = 'ui.bootstrap.module.timepicker'; + +angular.module(MODULE_NAME, ['ui.bootstrap.timepicker', 'uib/template/timepicker/timepicker.html']); + +module.exports = MODULE_NAME; diff --git a/src/timepicker/index.js b/src/timepicker/index.js new file mode 100644 index 0000000000..becf079680 --- /dev/null +++ b/src/timepicker/index.js @@ -0,0 +1,2 @@ +require('./timepicker.css'); +module.exports = require('./index-nocss.js'); diff --git a/src/timepicker/test/timepicker.spec.js b/src/timepicker/test/timepicker.spec.js index 797456724b..74ae439b0c 100644 --- a/src/timepicker/test/timepicker.spec.js +++ b/src/timepicker/test/timepicker.spec.js @@ -1,43 +1,58 @@ -describe('timepicker directive', function () { - var $rootScope, element; +describe('timepicker directive', function() { + var $rootScope, $compile, $templateCache, element, modelCtrl; beforeEach(module('ui.bootstrap.timepicker')); - beforeEach(module('template/timepicker/timepicker.html')); - beforeEach(inject(function(_$compile_, _$rootScope_) { + beforeEach(module('uib/template/timepicker/timepicker.html')); + beforeEach(inject(function(_$compile_, _$rootScope_, _$templateCache_) { $compile = _$compile_; $rootScope = _$rootScope_; - $rootScope.time = newTime(14, 40); + $rootScope.time = newTime(14, 40, 25); + $templateCache = _$templateCache_; - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); + + modelCtrl = element.controller('ngModel'); })); - function newTime(hours, minutes) { + function newTime(hours, minutes, seconds) { + seconds = seconds ? seconds : 0; var time = new Date(); time.setHours(hours); time.setMinutes(minutes); + time.setSeconds(seconds); + //this is required, otherwise rollover edges cases tests will have + //time reset to dates that are off by milliseconds. + time.setMilliseconds(0); return time; } - function getTimeState(withoutMeridian) { + function getTimeState(withoutMeridian, withoutSeconds) { var inputs = element.find('input'); + var limit = withoutSeconds ? 2 : 3; var state = []; - for (var i = 0; i < 2; i ++) { + for (var i = 0; i < limit; i ++) { state.push(inputs.eq(i).val()); } - if ( withoutMeridian !== true ) { - state.push( getMeridianButton().text() ); + + if (withoutMeridian !== true) { + state.push(getMeridianButton().text()); } + return state; } - function getModelState() { - return [ $rootScope.time.getHours(), $rootScope.time.getMinutes() ]; + function getModelState(withoutSeconds) { + if (withoutSeconds) { + return [$rootScope.time.getHours(), $rootScope.time.getMinutes()]; + } + + return [$rootScope.time.getHours(), $rootScope.time.getMinutes(), $rootScope.time.getSeconds()]; } function getArrow(isUp, tdIndex) { - return element.find('tr').eq( (isUp) ? 0 : 2 ).find('td').eq( tdIndex ).find('a').eq(0); + return element.find('tr').eq(isUp ? 0 : 2).find('td').eq(tdIndex).find('a').eq(0); } function getHoursButton(isUp) { @@ -48,6 +63,10 @@ describe('timepicker directive', function () { return getArrow(isUp, 2); } + function getSecondsButton(isUp) { + return getArrow(isUp, 4); + } + function getMeridianButton() { return element.find('button').eq(0); } @@ -64,43 +83,70 @@ describe('timepicker directive', function () { e.wheelDelta = delta; return e; } - + function wheelThatOtherMouse(delta) { var e = $.Event('wheel'); e.deltaY = delta; return e; } - it('contains three row & three input elements', function() { + function keydown(key) { + var e = $.Event('keydown'); + switch(key) { + case 'left': + e.which = 37; + break; + case 'up': + e.which = 38; + break; + case 'right': + e.which = 39; + break; + case 'down': + e.which = 40; + break; + } + return e; + } + + it('contains three row & four input elements', function() { expect(element.find('tr').length).toBe(3); - expect(element.find('input').length).toBe(2); + expect(element.find('input').length).toBe(3); expect(element.find('button').length).toBe(1); }); it('has initially the correct time & meridian', function() { - expect(getTimeState()).toEqual(['02', '40', 'PM']); - expect(getModelState()).toEqual([14, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + }); + + it('should be pristine', function() { + expect(modelCtrl.$pristine).toBe(true); + }); + + it('should be untouched', function() { + expect(modelCtrl.$untouched).toBe(true); }); it('has `selected` current time when model is initially cleared', function() { $rootScope.time = null; - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); expect($rootScope.time).toBe(null); - expect(getTimeState()).not.toEqual(['', '', '']); + expect(getTimeState()).not.toEqual(['', '', '' , '']); }); it('changes inputs when model changes value', function() { - $rootScope.time = newTime(11, 50); + $rootScope.time = newTime(11, 50, 20); $rootScope.$digest(); - expect(getTimeState()).toEqual(['11', '50', 'AM']); - expect(getModelState()).toEqual([11, 50]); + expect(getTimeState()).toEqual(['11', '50', '20', 'AM']); + expect(getModelState()).toEqual([11, 50, 20]); - $rootScope.time = newTime(16, 40); + $rootScope.time = newTime(16, 40, 45); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '40', 'PM']); - expect(getModelState()).toEqual([16, 40]); + expect(getTimeState()).toEqual(['04', '40', '45', 'PM']); + expect(getModelState()).toEqual([16, 40, 45]); }); it('increases / decreases hours when arrows are clicked', function() { @@ -108,16 +154,16 @@ describe('timepicker directive', function () { var down = getHoursButton(false); doClick(up); - expect(getTimeState()).toEqual(['03', '40', 'PM']); - expect(getModelState()).toEqual([15, 40]); + expect(getTimeState()).toEqual(['03', '40', '25', 'PM']); + expect(getModelState()).toEqual([15, 40, 25]); doClick(down); - expect(getTimeState()).toEqual(['02', '40', 'PM']); - expect(getModelState()).toEqual([14, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); doClick(down); - expect(getTimeState()).toEqual(['01', '40', 'PM']); - expect(getModelState()).toEqual([13, 40]); + expect(getTimeState()).toEqual(['01', '40', '25', 'PM']); + expect(getModelState()).toEqual([13, 40, 25]); }); it('increase / decreases minutes by default step when arrows are clicked', function() { @@ -125,32 +171,99 @@ describe('timepicker directive', function () { var down = getMinutesButton(false); doClick(up); - expect(getTimeState()).toEqual(['02', '41', 'PM']); - expect(getModelState()).toEqual([14, 41]); + expect(getTimeState()).toEqual(['02', '41', '25', 'PM']); + expect(getModelState()).toEqual([14, 41, 25]); doClick(down); - expect(getTimeState()).toEqual(['02', '40', 'PM']); - expect(getModelState()).toEqual([14, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); doClick(down); - expect(getTimeState()).toEqual(['02', '39', 'PM']); - expect(getModelState()).toEqual([14, 39]); + expect(getTimeState()).toEqual(['02', '39', '25', 'PM']); + expect(getModelState()).toEqual([14, 39, 25]); + }); + + it('increase / decreases seconds by default step when arrows are clicked', function() { + var up = getSecondsButton(true); + var down = getSecondsButton(false); + + doClick(up); + expect(getTimeState()).toEqual(['02', '40', '26', 'PM']); + expect(getModelState()).toEqual([14, 40, 26]); + + doClick(down); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + doClick(down); + expect(getTimeState()).toEqual(['02', '40', '24', 'PM']); + expect(getModelState()).toEqual([14, 40, 24]); + }); + + it('should be dirty when input changes', function() { + var upHours = getHoursButton(true); + var upMinutes = getMinutesButton(true); + var upSeconds = getSecondsButton(true); + + doClick(upHours); + expect(modelCtrl.$dirty).toBe(true); + + modelCtrl.$setPristine(); + + doClick(upMinutes); + expect(modelCtrl.$dirty).toBe(true); + + modelCtrl.$setPristine(); + + doClick(upSeconds); + expect(modelCtrl.$dirty).toBe(true); + }); + + it('should be touched when input blurs', function() { + var inputs = element.find('input'); + var hoursInput = inputs.eq(0), + minutesInput = inputs.eq(1), + secondsInput = inputs.eq(2); + + hoursInput.val(12); + $rootScope.$digest(); + hoursInput.blur(); + expect(modelCtrl.$touched).toBe(true); + + modelCtrl.$setUntouched(); + + minutesInput.val(20); + $rootScope.$digest(); + hoursInput.blur(); + expect(modelCtrl.$touched).toBe(true); + + modelCtrl.$setUntouched(); + + secondsInput.val(9); + $rootScope.$digest(); + hoursInput.blur(); + expect(modelCtrl.$touched).toBe(true); + }); + + it('meridian button has correct type', function() { + var button = getMeridianButton(); + expect(button.attr('type')).toBe('button'); }); - it('toggles meridian when arrows are clicked', function() { + it('toggles meridian when button is clicked', function() { var button = getMeridianButton(); doClick(button); - expect(getTimeState()).toEqual(['02', '40', 'AM']); - expect(getModelState()).toEqual([2, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'AM']); + expect(getModelState()).toEqual([2, 40, 25]); doClick(button); - expect(getTimeState()).toEqual(['02', '40', 'PM']); - expect(getModelState()).toEqual([14, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); doClick(button); - expect(getTimeState()).toEqual(['02', '40', 'AM']); - expect(getModelState()).toEqual([2, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'AM']); + expect(getModelState()).toEqual([2, 40, 25]); }); it('has minutes "connected" to hours', function() { @@ -158,27 +271,55 @@ describe('timepicker directive', function () { var down = getMinutesButton(false); doClick(up, 10); - expect(getTimeState()).toEqual(['02', '50', 'PM']); - expect(getModelState()).toEqual([14, 50]); + expect(getTimeState()).toEqual(['02', '50', '25', 'PM']); + expect(getModelState()).toEqual([14, 50, 25]); doClick(up, 10); - expect(getTimeState()).toEqual(['03', '00', 'PM']); - expect(getModelState()).toEqual([15, 0]); + expect(getTimeState()).toEqual(['03', '00', '25', 'PM']); + expect(getModelState()).toEqual([15, 0, 25]); doClick(up, 10); $rootScope.$digest(); - expect(getTimeState()).toEqual(['03', '10', 'PM']); - expect(getModelState()).toEqual([15, 10]); + expect(getTimeState()).toEqual(['03', '10', '25', 'PM']); + expect(getModelState()).toEqual([15, 10, 25]); doClick(down, 10); $rootScope.$digest(); - expect(getTimeState()).toEqual(['03', '00', 'PM']); - expect(getModelState()).toEqual([15, 0]); + expect(getTimeState()).toEqual(['03', '00', '25', 'PM']); + expect(getModelState()).toEqual([15, 0, 25]); doClick(down, 10); $rootScope.$digest(); - expect(getTimeState()).toEqual(['02', '50', 'PM']); - expect(getModelState()).toEqual([14, 50]); + expect(getTimeState()).toEqual(['02', '50', '25', 'PM']); + expect(getModelState()).toEqual([14, 50, 25]); + }); + + it('has seconds "connected" to minutes', function() { + var up = getSecondsButton(true); + var down = getSecondsButton(false); + + doClick(up, 15); + expect(getTimeState()).toEqual(['02', '40', '40', 'PM']); + expect(getModelState()).toEqual([14, 40, 40]); + + doClick(up, 15); + expect(getTimeState()).toEqual(['02', '40', '55', 'PM']); + expect(getModelState()).toEqual([14, 40, 55]); + + doClick(up, 15); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '41', '10', 'PM']); + expect(getModelState()).toEqual([14, 41, 10]); + + doClick(down, 15); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '55', 'PM']); + expect(getModelState()).toEqual([14, 40, 55]); + + doClick(down, 15); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '40', 'PM']); + expect(getModelState()).toEqual([14, 40, 40]); }); it('has hours "connected" to meridian', function() { @@ -186,197 +327,328 @@ describe('timepicker directive', function () { var down = getHoursButton(false); // AM -> PM - $rootScope.time = newTime(11, 0); + $rootScope.time = newTime(11, 0, 25); $rootScope.$digest(); - expect(getTimeState()).toEqual(['11', '00', 'AM']); - expect(getModelState()).toEqual([11, 0]); + expect(getTimeState()).toEqual(['11', '00', '25', 'AM']); + expect(getModelState()).toEqual([11, 0, 25]); doClick(up); - expect(getTimeState()).toEqual(['12', '00', 'PM']); - expect(getModelState()).toEqual([12, 0]); + expect(getTimeState()).toEqual(['12', '00', '25', 'PM']); + expect(getModelState()).toEqual([12, 0, 25]); doClick(up); - expect(getTimeState()).toEqual(['01', '00', 'PM']); - expect(getModelState()).toEqual([13, 0]); + expect(getTimeState()).toEqual(['01', '00', '25', 'PM']); + expect(getModelState()).toEqual([13, 0, 25]); doClick(down); - expect(getTimeState()).toEqual(['12', '00', 'PM']); - expect(getModelState()).toEqual([12, 0]); + expect(getTimeState()).toEqual(['12', '00', '25', 'PM']); + expect(getModelState()).toEqual([12, 0, 25]); doClick(down); - expect(getTimeState()).toEqual(['11', '00', 'AM']); - expect(getModelState()).toEqual([11, 0]); + expect(getTimeState()).toEqual(['11', '00', '25', 'AM']); + expect(getModelState()).toEqual([11, 0, 25]); // PM -> AM - $rootScope.time = newTime(23, 0); + $rootScope.time = newTime(23, 0, 25); $rootScope.$digest(); - expect(getTimeState()).toEqual(['11', '00', 'PM']); - expect(getModelState()).toEqual([23, 0]); + expect(getTimeState()).toEqual(['11', '00', '25', 'PM']); + expect(getModelState()).toEqual([23, 0, 25]); doClick(up); - expect(getTimeState()).toEqual(['12', '00', 'AM']); - expect(getModelState()).toEqual([0, 0]); + expect(getTimeState()).toEqual(['12', '00', '25', 'AM']); + expect(getModelState()).toEqual([0, 0, 25]); doClick(up); - expect(getTimeState()).toEqual(['01', '00', 'AM']); - expect(getModelState()).toEqual([01, 0]); + expect(getTimeState()).toEqual(['01', '00', '25', 'AM']); + expect(getModelState()).toEqual([1, 0, 25]); doClick(down); - expect(getTimeState()).toEqual(['12', '00', 'AM']); - expect(getModelState()).toEqual([0, 0]); + expect(getTimeState()).toEqual(['12', '00', '25', 'AM']); + expect(getModelState()).toEqual([0, 0, 25]); doClick(down); - expect(getTimeState()).toEqual(['11', '00', 'PM']); - expect(getModelState()).toEqual([23, 0]); + expect(getTimeState()).toEqual(['11', '00', '25', 'PM']); + expect(getModelState()).toEqual([23, 0, 25]); }); it('changes only the time part when hours change', function() { - $rootScope.time = newTime(23, 50); + $rootScope.time = newTime(23, 50, 20); $rootScope.$digest(); - var date = $rootScope.time.getDate(); + var date = $rootScope.time.getDate(); var up = getHoursButton(true); doClick(up); - expect(getTimeState()).toEqual(['12', '50', 'AM']); - expect(getModelState()).toEqual([0, 50]); + expect(getTimeState()).toEqual(['12', '50', '20', 'AM']); + expect(getModelState()).toEqual([0, 50, 20]); expect(date).toEqual($rootScope.time.getDate()); }); it('changes only the time part when minutes change', function() { - element = $compile('')($rootScope); - $rootScope.time = newTime(0, 0); + element = $compile('')($rootScope); + $rootScope.time = newTime(0, 0, 0); $rootScope.$digest(); - var date = $rootScope.time.getDate(); + var date = $rootScope.time.getDate(); var up = getMinutesButton(true); doClick(up, 2); - expect(getTimeState()).toEqual(['12', '30', 'AM']); - expect(getModelState()).toEqual([0, 30]); + expect(getTimeState()).toEqual(['12', '30', '00', 'AM']); + expect(getModelState()).toEqual([0, 30, 0]); expect(date).toEqual($rootScope.time.getDate()); var down = getMinutesButton(false); doClick(down, 2); - expect(getTimeState()).toEqual(['12', '00', 'AM']); - expect(getModelState()).toEqual([0, 0]); + expect(getTimeState()).toEqual(['12', '00', '00', 'AM']); + expect(getModelState()).toEqual([0, 0, 0]); expect(date).toEqual($rootScope.time.getDate()); doClick(down, 2); - expect(getTimeState()).toEqual(['11', '30', 'PM']); - expect(getModelState()).toEqual([23, 30]); + expect(getTimeState()).toEqual(['11', '30', '00', 'PM']); + expect(getModelState()).toEqual([23, 30, 0]); expect(date).toEqual($rootScope.time.getDate()); }); it('responds properly on "mousewheel" events', function() { var inputs = element.find('input'); - var hoursEl = inputs.eq(0), minutesEl = inputs.eq(1); + var hoursEl = inputs.eq(0), minutesEl = inputs.eq(1), secondsEl = inputs.eq(2); var upMouseWheelEvent = wheelThatMouse(1); var downMouseWheelEvent = wheelThatMouse(-1); - expect(getTimeState()).toEqual(['02', '40', 'PM']); - expect(getModelState()).toEqual([14, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); // UP - hoursEl.trigger( upMouseWheelEvent ); + hoursEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['03', '40', '25', 'PM']); + expect(getModelState()).toEqual([15, 40, 25]); + + hoursEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '40', '25', 'PM']); + expect(getModelState()).toEqual([16, 40, 25]); + + minutesEl.trigger(upMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['03', '40', 'PM']); - expect(getModelState()).toEqual([15, 40]); + expect(getTimeState()).toEqual(['04', '41', '25', 'PM']); + expect(getModelState()).toEqual([16, 41, 25]); - hoursEl.trigger( upMouseWheelEvent ); + minutesEl.trigger(upMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '40', 'PM']); - expect(getModelState()).toEqual([16, 40]); + expect(getTimeState()).toEqual(['04', '42', '25', 'PM']); + expect(getModelState()).toEqual([16, 42, 25]); - minutesEl.trigger( upMouseWheelEvent ); + secondsEl.trigger(upMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '41', 'PM']); - expect(getModelState()).toEqual([16, 41]); + expect(getTimeState()).toEqual(['04', '42', '26', 'PM']); + expect(getModelState()).toEqual([16, 42, 26]); - minutesEl.trigger( upMouseWheelEvent ); + secondsEl.trigger(upMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '42', 'PM']); - expect(getModelState()).toEqual([16, 42]); + expect(getTimeState()).toEqual(['04', '42', '27', 'PM']); + expect(getModelState()).toEqual([16, 42, 27]); // DOWN - minutesEl.trigger( downMouseWheelEvent ); + secondsEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '42', '26', 'PM']); + expect(getModelState()).toEqual([16, 42, 26]); + + secondsEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '42', '25', 'PM']); + expect(getModelState()).toEqual([16, 42, 25]); + + minutesEl.trigger(downMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '41', 'PM']); - expect(getModelState()).toEqual([16, 41]); + expect(getTimeState()).toEqual(['04', '41', '25', 'PM']); + expect(getModelState()).toEqual([16, 41, 25]); - minutesEl.trigger( downMouseWheelEvent ); + minutesEl.trigger(downMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '40', 'PM']); - expect(getModelState()).toEqual([16, 40]); + expect(getTimeState()).toEqual(['04', '40', '25', 'PM']); + expect(getModelState()).toEqual([16, 40, 25]); - hoursEl.trigger( downMouseWheelEvent ); + hoursEl.trigger(downMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['03', '40', 'PM']); - expect(getModelState()).toEqual([15, 40]); + expect(getTimeState()).toEqual(['03', '40', '25', 'PM']); + expect(getModelState()).toEqual([15, 40, 25]); - hoursEl.trigger( downMouseWheelEvent ); + hoursEl.trigger(downMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['02', '40', 'PM']); - expect(getModelState()).toEqual([14, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); }); it('responds properly on "wheel" events', function() { var inputs = element.find('input'); - var hoursEl = inputs.eq(0), minutesEl = inputs.eq(1); + var hoursEl = inputs.eq(0), minutesEl = inputs.eq(1), secondsEl = inputs.eq(2); var upMouseWheelEvent = wheelThatOtherMouse(-1); var downMouseWheelEvent = wheelThatOtherMouse(1); - expect(getTimeState()).toEqual(['02', '40', 'PM']); - expect(getModelState()).toEqual([14, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + // UP + hoursEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['03', '40', '25', 'PM']); + expect(getModelState()).toEqual([15, 40, 25]); + + hoursEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '40', '25', 'PM']); + expect(getModelState()).toEqual([16, 40, 25]); + + minutesEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '41', '25', 'PM']); + expect(getModelState()).toEqual([16, 41, 25]); + + minutesEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '42', '25', 'PM']); + expect(getModelState()).toEqual([16, 42, 25]); + + secondsEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '42', '26', 'PM']); + expect(getModelState()).toEqual([16, 42, 26]); + + secondsEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '42', '27', 'PM']); + expect(getModelState()).toEqual([16, 42, 27]); + + // DOWN + secondsEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '42', '26', 'PM']); + expect(getModelState()).toEqual([16, 42, 26]); + + secondsEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '42', '25', 'PM']); + expect(getModelState()).toEqual([16, 42, 25]); + + minutesEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '41', '25', 'PM']); + expect(getModelState()).toEqual([16, 41, 25]); + + minutesEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '40', '25', 'PM']); + expect(getModelState()).toEqual([16, 40, 25]); + + hoursEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['03', '40', '25', 'PM']); + expect(getModelState()).toEqual([15, 40, 25]); + + hoursEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + }); + + it('responds properly on "keydown" events', function() { + var inputs = element.find('input'); + var hoursEl = inputs.eq(0), minutesEl = inputs.eq(1), + secondsEl = inputs.eq(2); + var upKeydownEvent = keydown('up'); + var downKeydownEvent = keydown('down'); + var leftKeydownEvent = keydown('left'); + + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); // UP - hoursEl.trigger( upMouseWheelEvent ); + hoursEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['03', '40', '25', 'PM']); + expect(getModelState()).toEqual([15, 40, 25]); + + hoursEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '40', '25', 'PM']); + expect(getModelState()).toEqual([16, 40, 25]); + + minutesEl.trigger(upKeydownEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['03', '40', 'PM']); - expect(getModelState()).toEqual([15, 40]); + expect(getTimeState()).toEqual(['04', '41', '25', 'PM']); + expect(getModelState()).toEqual([16, 41, 25]); - hoursEl.trigger( upMouseWheelEvent ); + minutesEl.trigger(upKeydownEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '40', 'PM']); - expect(getModelState()).toEqual([16, 40]); + expect(getTimeState()).toEqual(['04', '42', '25', 'PM']); + expect(getModelState()).toEqual([16, 42, 25]); - minutesEl.trigger( upMouseWheelEvent ); + secondsEl.trigger(upKeydownEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '41', 'PM']); - expect(getModelState()).toEqual([16, 41]); + expect(getTimeState()).toEqual(['04', '42', '26', 'PM']); + expect(getModelState()).toEqual([16, 42, 26]); - minutesEl.trigger( upMouseWheelEvent ); + secondsEl.trigger(upKeydownEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '42', 'PM']); - expect(getModelState()).toEqual([16, 42]); + expect(getTimeState()).toEqual(['04', '42', '27', 'PM']); + expect(getModelState()).toEqual([16, 42, 27]); // DOWN - minutesEl.trigger( downMouseWheelEvent ); + secondsEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '42', '26', 'PM']); + expect(getModelState()).toEqual([16, 42, 26]); + + secondsEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '42', '25', 'PM']); + expect(getModelState()).toEqual([16, 42, 25]); + + minutesEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '41', '25', 'PM']); + expect(getModelState()).toEqual([16, 41, 25]); + + minutesEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '40', '25', 'PM']); + expect(getModelState()).toEqual([16, 40, 25]); + + hoursEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['03', '40', '25', 'PM']); + expect(getModelState()).toEqual([15, 40, 25]); + + hoursEl.trigger(downKeydownEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '41', 'PM']); - expect(getModelState()).toEqual([16, 41]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); - minutesEl.trigger( downMouseWheelEvent ); + // Other keydown + hoursEl.trigger(leftKeydownEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '40', 'PM']); - expect(getModelState()).toEqual([16, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); - hoursEl.trigger( downMouseWheelEvent ); + minutesEl.trigger(leftKeydownEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['03', '40', 'PM']); - expect(getModelState()).toEqual([15, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); - hoursEl.trigger( downMouseWheelEvent ); + secondsEl.trigger(leftKeydownEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['02', '40', 'PM']); - expect(getModelState()).toEqual([14, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); }); - describe('attributes', function () { + describe('attributes', function() { beforeEach(function() { $rootScope.hstep = 2; $rootScope.mstep = 30; - $rootScope.time = newTime(14, 0); - element = $compile('')($rootScope); + $rootScope.sstep = 30; + $rootScope.time = newTime(14, 0 , 0); + element = $compile('')($rootScope); $rootScope.$digest(); }); @@ -384,32 +656,32 @@ describe('timepicker directive', function () { var up = getHoursButton(true); var down = getHoursButton(false); - expect(getTimeState()).toEqual(['02', '00', 'PM']); - expect(getModelState()).toEqual([14, 0]); + expect(getTimeState()).toEqual(['02', '00', '00', 'PM']); + expect(getModelState()).toEqual([14, 0, 0]); doClick(up); - expect(getTimeState()).toEqual(['04', '00', 'PM']); - expect(getModelState()).toEqual([16, 0]); + expect(getTimeState()).toEqual(['04', '00', '00', 'PM']); + expect(getModelState()).toEqual([16, 0, 0]); doClick(down); - expect(getTimeState()).toEqual(['02', '00', 'PM']); - expect(getModelState()).toEqual([14, 0]); + expect(getTimeState()).toEqual(['02', '00', '00', 'PM']); + expect(getModelState()).toEqual([14, 0, 0]); doClick(down); - expect(getTimeState()).toEqual(['12', '00', 'PM']); - expect(getModelState()).toEqual([12, 0]); + expect(getTimeState()).toEqual(['12', '00', '00', 'PM']); + expect(getModelState()).toEqual([12, 0, 0]); // Change step $rootScope.hstep = 3; $rootScope.$digest(); doClick(up); - expect(getTimeState()).toEqual(['03', '00', 'PM']); - expect(getModelState()).toEqual([15, 0]); + expect(getTimeState()).toEqual(['03', '00', '00', 'PM']); + expect(getModelState()).toEqual([15, 0, 0]); doClick(down); - expect(getTimeState()).toEqual(['12', '00', 'PM']); - expect(getModelState()).toEqual([12, 0]); + expect(getTimeState()).toEqual(['12', '00', '00', 'PM']); + expect(getModelState()).toEqual([12, 0, 0]); }); it('increases / decreases minutes by configurable step', function() { @@ -417,185 +689,354 @@ describe('timepicker directive', function () { var down = getMinutesButton(false); doClick(up); - expect(getTimeState()).toEqual(['02', '30', 'PM']); - expect(getModelState()).toEqual([14, 30]); + expect(getTimeState()).toEqual(['02', '30', '00', 'PM']); + expect(getModelState()).toEqual([14, 30, 0]); doClick(up); - expect(getTimeState()).toEqual(['03', '00', 'PM']); - expect(getModelState()).toEqual([15, 0]); + expect(getTimeState()).toEqual(['03', '00', '00', 'PM']); + expect(getModelState()).toEqual([15, 0, 0]); doClick(down); - expect(getTimeState()).toEqual(['02', '30', 'PM']); - expect(getModelState()).toEqual([14, 30]); + expect(getTimeState()).toEqual(['02', '30', '00', 'PM']); + expect(getModelState()).toEqual([14, 30, 0]); doClick(down); - expect(getTimeState()).toEqual(['02', '00', 'PM']); - expect(getModelState()).toEqual([14, 0]); + expect(getTimeState()).toEqual(['02', '00', '00', 'PM']); + expect(getModelState()).toEqual([14, 0, 0]); // Change step $rootScope.mstep = 15; $rootScope.$digest(); doClick(up); - expect(getTimeState()).toEqual(['02', '15', 'PM']); - expect(getModelState()).toEqual([14, 15]); + expect(getTimeState()).toEqual(['02', '15', '00', 'PM']); + expect(getModelState()).toEqual([14, 15, 0]); doClick(down); - expect(getTimeState()).toEqual(['02', '00', 'PM']); - expect(getModelState()).toEqual([14, 0]); + expect(getTimeState()).toEqual(['02', '00', '00', 'PM']); + expect(getModelState()).toEqual([14, 0, 0]); doClick(down); - expect(getTimeState()).toEqual(['01', '45', 'PM']); - expect(getModelState()).toEqual([13, 45]); + expect(getTimeState()).toEqual(['01', '45', '00', 'PM']); + expect(getModelState()).toEqual([13, 45, 0]); }); it('responds properly on "mousewheel" events with configurable steps', function() { var inputs = element.find('input'); - var hoursEl = inputs.eq(0), minutesEl = inputs.eq(1); + var hoursEl = inputs.eq(0), minutesEl = inputs.eq(1), secondsEl = inputs.eq(2); var upMouseWheelEvent = wheelThatMouse(1); var downMouseWheelEvent = wheelThatMouse(-1); - expect(getTimeState()).toEqual(['02', '00', 'PM']); - expect(getModelState()).toEqual([14, 0]); + expect(getTimeState()).toEqual(['02', '00', '00', 'PM']); + expect(getModelState()).toEqual([14, 0, 0]); // UP - hoursEl.trigger( upMouseWheelEvent ); + hoursEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '00', '00', 'PM']); + expect(getModelState()).toEqual([16, 0, 0]); + + minutesEl.trigger(upMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '00', 'PM']); - expect(getModelState()).toEqual([16, 0]); + expect(getTimeState()).toEqual(['04', '30', '00', 'PM']); + expect(getModelState()).toEqual([16, 30, 0]); - minutesEl.trigger( upMouseWheelEvent ); + secondsEl.trigger(upMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '30', 'PM']); - expect(getModelState()).toEqual([16, 30]); + expect(getTimeState()).toEqual(['04', '30', '30', 'PM']); + expect(getModelState()).toEqual([16, 30, 30]); // DOWN - minutesEl.trigger( downMouseWheelEvent ); + + secondsEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '30', '00', 'PM']); + expect(getModelState()).toEqual([16, 30, 0]); + + minutesEl.trigger(downMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '00', 'PM']); - expect(getModelState()).toEqual([16, 0]); + expect(getTimeState()).toEqual(['04', '00', '00', 'PM']); + expect(getModelState()).toEqual([16, 0, 0]); - hoursEl.trigger( downMouseWheelEvent ); + hoursEl.trigger(downMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['02', '00', 'PM']); - expect(getModelState()).toEqual([14, 0]); + expect(getTimeState()).toEqual(['02', '00', '00', 'PM']); + expect(getModelState()).toEqual([14, 0, 0]); }); - + it('responds properly on "wheel" events with configurable steps', function() { var inputs = element.find('input'); - var hoursEl = inputs.eq(0), minutesEl = inputs.eq(1); + var hoursEl = inputs.eq(0), minutesEl = inputs.eq(1), secondsEl = inputs.eq(2); var upMouseWheelEvent = wheelThatOtherMouse(-1); var downMouseWheelEvent = wheelThatOtherMouse(1); - expect(getTimeState()).toEqual(['02', '00', 'PM']); - expect(getModelState()).toEqual([14, 0]); + expect(getTimeState()).toEqual(['02', '00', '00', 'PM']); + expect(getModelState()).toEqual([14, 0, 0]); // UP - hoursEl.trigger( upMouseWheelEvent ); + hoursEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['04', '00', '00', 'PM']); + expect(getModelState()).toEqual([16, 0, 0]); + + minutesEl.trigger(upMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '00', 'PM']); - expect(getModelState()).toEqual([16, 0]); + expect(getTimeState()).toEqual(['04', '30', '00', 'PM']); + expect(getModelState()).toEqual([16, 30, 0]); - minutesEl.trigger( upMouseWheelEvent ); + secondsEl.trigger(upMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '30', 'PM']); - expect(getModelState()).toEqual([16, 30]); + expect(getTimeState()).toEqual(['04', '30', '30', 'PM']); + expect(getModelState()).toEqual([16, 30, 30]); // DOWN - minutesEl.trigger( downMouseWheelEvent ); + + secondsEl.trigger(downMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['04', '00', 'PM']); - expect(getModelState()).toEqual([16, 0]); + expect(getTimeState()).toEqual(['04', '30', '00', 'PM']); + expect(getModelState()).toEqual([16, 30, 0]); - hoursEl.trigger( downMouseWheelEvent ); + minutesEl.trigger(downMouseWheelEvent); $rootScope.$digest(); - expect(getTimeState()).toEqual(['02', '00', 'PM']); - expect(getModelState()).toEqual([14, 0]); + expect(getTimeState()).toEqual(['04', '00', '00', 'PM']); + expect(getModelState()).toEqual([16, 0, 0]); + + hoursEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '00', '00', 'PM']); + expect(getModelState()).toEqual([14, 0, 0]); }); it('can handle strings as steps', function() { var upHours = getHoursButton(true); var upMinutes = getMinutesButton(true); + var upSeconds = getSecondsButton(true); - expect(getTimeState()).toEqual(['02', '00', 'PM']); - expect(getModelState()).toEqual([14, 0]); + expect(getTimeState()).toEqual(['02', '00', '00', 'PM']); + expect(getModelState()).toEqual([14, 0, 0]); $rootScope.hstep = '4'; $rootScope.mstep = '20'; + $rootScope.sstep = '20'; $rootScope.$digest(); doClick(upHours); - expect(getTimeState()).toEqual(['06', '00', 'PM']); - expect(getModelState()).toEqual([18, 0]); + expect(getTimeState()).toEqual(['06', '00', '00', 'PM']); + expect(getModelState()).toEqual([18, 0, 0]); doClick(upMinutes); - expect(getTimeState()).toEqual(['06', '20', 'PM']); - expect(getModelState()).toEqual([18, 20]); + expect(getTimeState()).toEqual(['06', '20', '00', 'PM']); + expect(getModelState()).toEqual([18, 20, 0]); + + doClick(upSeconds); + expect(getTimeState()).toEqual(['06', '20', '20', 'PM']); + expect(getModelState()).toEqual([18, 20, 20]); + + }); + + }); + + describe('without seconds mode',function(){ + beforeEach(function(){ + $rootScope.displaysSeconds = false; + $rootScope.time = newTime(14,40,35); + element = $compile('')($rootScope); + $rootScope.$digest(); + }); + + it('increases / decreases hours when arrows are clicked', function() { + var up = getHoursButton(true); + var down = getHoursButton(false); + + doClick(up); + expect(getTimeState(false, true)).toEqual(['03', '40', 'PM']); + expect(getModelState(true)).toEqual([15, 40]); + + doClick(down); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + + doClick(down); + expect(getTimeState(false, true)).toEqual(['01', '40', 'PM']); + expect(getModelState(true)).toEqual([13, 40]); + + }); + + it('increase / decreases minutes by default step when arrows are clicked', function() { + var up = getMinutesButton(true); + var down = getMinutesButton(false); + + doClick(up); + expect(getTimeState(false, true)).toEqual(['02', '41', 'PM']); + expect(getModelState(true)).toEqual([14, 41]); + + doClick(down); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + + doClick(down); + expect(getTimeState(false, true)).toEqual(['02', '39', 'PM']); + expect(getModelState(true)).toEqual([14, 39]); + }); + + it('has minutes "connected" to hours', function() { + + var up = getMinutesButton(true); + var down = getMinutesButton(false); + + doClick(up, 10); + expect(getTimeState(false, true)).toEqual(['02', '50', 'PM']); + expect(getModelState(true)).toEqual([14, 50]); + + doClick(up, 10); + expect(getTimeState(false, true)).toEqual(['03', '00', 'PM']); + expect(getModelState(true)).toEqual([15, 0]); + + doClick(up, 10); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['03', '10', 'PM']); + expect(getModelState(true)).toEqual([15, 10]); + + doClick(down, 10); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['03', '00', 'PM']); + expect(getModelState(true)).toEqual([15, 0]); + + doClick(down, 10); + $rootScope.$digest(); + expect(getTimeState(false,true)).toEqual(['02', '50', 'PM']); + expect(getModelState(true)).toEqual([14, 50]); }); }); - describe('12 / 24 hour mode', function () { + describe('12 / 24 hour mode', function() { beforeEach(function() { $rootScope.meridian = false; - $rootScope.time = newTime(14, 10); - element = $compile('')($rootScope); + $rootScope.time = newTime(14, 10, 20); + element = $compile('')($rootScope); $rootScope.$digest(); }); function getMeridianTd() { - return element.find('tr').eq(1).find('td').eq(3); + return element.find('tr').eq(1).find('td').eq(5); } it('initially displays correct time when `show-meridian` is false', function() { - expect(getTimeState(true)).toEqual(['14', '10']); - expect(getModelState()).toEqual([14, 10]); - expect(getMeridianTd().css('display')).toBe('none'); + expect(getTimeState(true)).toEqual(['14', '10', '20']); + expect(getModelState()).toEqual([14, 10, 20]); + expect(getMeridianTd()).toBeHidden(); }); it('toggles correctly between different modes', function() { - expect(getTimeState(true)).toEqual(['14', '10']); + expect(getTimeState(true)).toEqual(['14', '10', '20']); $rootScope.meridian = true; $rootScope.$digest(); - expect(getTimeState()).toEqual(['02', '10', 'PM']); - expect(getModelState()).toEqual([14, 10]); - expect(getMeridianTd().css('display')).not.toBe('none'); + expect(getTimeState()).toEqual(['02', '10', '20', 'PM']); + expect(getModelState()).toEqual([14, 10, 20]); + expect(getMeridianTd()).not.toBeHidden(); $rootScope.meridian = false; $rootScope.$digest(); - expect(getTimeState(true)).toEqual(['14', '10']); - expect(getModelState()).toEqual([14, 10]); - expect(getMeridianTd().css('display')).toBe('none'); + expect(getTimeState(true)).toEqual(['14', '10', '20']); + expect(getModelState()).toEqual([14, 10, 20]); + expect(getMeridianTd()).toBeHidden(); }); it('handles correctly initially empty model on parent element', function() { $rootScope.time = null; - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); expect($rootScope.time).toBe(null); }); }); - describe('setting timepickerConfig steps', function() { + describe('`meridians` attribute', function() { + beforeEach(inject(function() { + $rootScope.meridiansArray = ['am', 'pm']; + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('displays correctly', function() { + expect(getTimeState()[3]).toBe('pm'); + }); + + it('toggles correctly', function() { + $rootScope.time = newTime(2, 40, 20); + $rootScope.$digest(); + expect(getTimeState()[3]).toBe('am'); + }); + }); + + describe('`readonly-input` attribute', function() { + beforeEach(inject(function() { + $rootScope.meridiansArray = ['am', 'pm']; + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('should make inputs readonly', function() { + var inputs = element.find('input'); + expect(inputs.eq(0).attr('readonly')).toBe('readonly'); + expect(inputs.eq(1).attr('readonly')).toBe('readonly'); + expect(inputs.eq(2).attr('readonly')).toBe('readonly'); + }); + }); + + describe('`pad-hours` attribute', function() { + function triggerInput(elem, val) { + elem.val(val); + elem.trigger('input'); + } + + it('should pad the hours by default', function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + + var inputs = element.find('input'); + var hoursInput = inputs.eq(0); + triggerInput(hoursInput, 4); + hoursInput.blur(); + + expect(hoursInput.val()).toBe('04'); + }); + + it('should not pad the hours', function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + + var inputs = element.find('input'); + var hoursInput = inputs.eq(0); + triggerInput(hoursInput, 4); + hoursInput.blur(); + + expect(hoursInput.val()).toBe('4'); + }); + }); + + describe('setting uibTimepickerConfig steps', function() { var originalConfig = {}; - beforeEach(inject(function(_$compile_, _$rootScope_, timepickerConfig) { - angular.extend(originalConfig, timepickerConfig); - timepickerConfig.hourStep = 2; - timepickerConfig.minuteStep = 10; - timepickerConfig.showMeridian = false; - element = $compile('')($rootScope); + beforeEach(inject(function(_$compile_, _$rootScope_, uibTimepickerConfig) { + angular.extend(originalConfig, uibTimepickerConfig); + uibTimepickerConfig.hourStep = 2; + uibTimepickerConfig.minuteStep = 10; + uibTimepickerConfig.secondStep = 10; + uibTimepickerConfig.showMeridian = false; + element = $compile('')($rootScope); $rootScope.$digest(); })); - afterEach(inject(function(timepickerConfig) { + + afterEach(inject(function(uibTimepickerConfig) { // return it to the original state - angular.extend(timepickerConfig, originalConfig); + angular.extend(uibTimepickerConfig, originalConfig); })); - it('does not affect the initial value', function () { - expect(getTimeState(true)).toEqual(['14', '40']); - expect(getModelState()).toEqual([14, 40]); + it('does not affect the initial value', function() { + expect(getTimeState(true)).toEqual(['14', '40', '25']); + expect(getModelState()).toEqual([14, 40, 25]); }); it('increases / decreases hours with configured step', function() { @@ -603,12 +1044,12 @@ describe('timepicker directive', function () { var down = getHoursButton(false); doClick(up, 2); - expect(getTimeState(true)).toEqual(['18', '40']); - expect(getModelState()).toEqual([18, 40]); + expect(getTimeState(true)).toEqual(['18', '40', '25']); + expect(getModelState()).toEqual([18, 40, 25]); doClick(down, 3); - expect(getTimeState(true)).toEqual(['12', '40']); - expect(getModelState()).toEqual([12, 40]); + expect(getTimeState(true)).toEqual(['12', '40', '25']); + expect(getModelState()).toEqual([12, 40, 25]); }); it('increases / decreases minutes with configured step', function() { @@ -616,48 +1057,114 @@ describe('timepicker directive', function () { var down = getMinutesButton(false); doClick(up); - expect(getTimeState(true)).toEqual(['14', '50']); - expect(getModelState()).toEqual([14, 50]); + expect(getTimeState(true)).toEqual(['14', '50', '25']); + expect(getModelState()).toEqual([14, 50, 25]); + + doClick(down, 3); + expect(getTimeState(true)).toEqual(['14', '20' , '25']); + expect(getModelState()).toEqual([14, 20, 25]); + }); + + it('increases / decreases seconds with configured step', function() { + var up = getSecondsButton(true); + var down = getSecondsButton(false); + + doClick(up); + expect(getTimeState(true)).toEqual(['14', '40', '35']); + expect(getModelState()).toEqual([14, 40, 35]); doClick(down, 3); - expect(getTimeState(true)).toEqual(['14', '20']); - expect(getModelState()).toEqual([14, 20]); + expect(getTimeState(true)).toEqual(['14', '40', '05']); + expect(getModelState()).toEqual([14, 40, 5]); }); + }); - describe('setting timepickerConfig meridian labels', function() { + describe('setting uibTimepickerConfig meridian labels', function() { var originalConfig = {}; - beforeEach(inject(function(_$compile_, _$rootScope_, timepickerConfig) { - angular.extend(originalConfig, timepickerConfig); - timepickerConfig.meridians = ['π.μ.', 'μ.μ.']; - timepickerConfig.showMeridian = true; - element = $compile('')($rootScope); + beforeEach(inject(function(_$compile_, _$rootScope_, uibTimepickerConfig) { + angular.extend(originalConfig, uibTimepickerConfig); + uibTimepickerConfig.meridians = ['π.μ.', 'μ.μ.']; + uibTimepickerConfig.showMeridian = true; + element = $compile('')($rootScope); $rootScope.$digest(); })); - afterEach(inject(function(timepickerConfig) { + afterEach(inject(function(uibTimepickerConfig) { // return it to the original state - angular.extend(timepickerConfig, originalConfig); + angular.extend(uibTimepickerConfig, originalConfig); })); - it('displays correctly', function () { - expect(getTimeState()).toEqual(['02', '40', 'μ.μ.']); - expect(getModelState()).toEqual([14, 40]); + it('displays correctly', function() { + expect(getTimeState()).toEqual(['02', '40', '25', 'μ.μ.']); + expect(getModelState()).toEqual([14, 40, 25]); }); - it('toggles correctly', function () { - $rootScope.time = newTime(2, 40); + it('toggles correctly', function() { + $rootScope.time = newTime(2, 40, 20); + $rootScope.$digest(); + + expect(getTimeState()).toEqual(['02', '40', '20', 'π.μ.']); + expect(getModelState()).toEqual([2, 40, 20]); + }); + }); + + describe('setting uibTimepickerConfig template url', function() { + var originalConfig = {}; + var newTemplateUrl = 'foo/bar.html'; + beforeEach(inject(function(_$compile_, _$rootScope_, uibTimepickerConfig) { + angular.extend(originalConfig, uibTimepickerConfig); + $templateCache.put(newTemplateUrl, 'baz'); + uibTimepickerConfig.templateUrl = newTemplateUrl; + + element = $compile('')($rootScope); $rootScope.$digest(); + })); + afterEach(inject(function(uibTimepickerConfig) { + // return it to the original state + angular.extend(uibTimepickerConfig, originalConfig); + })); + + it('should use a custom template', function() { + expect(element[0].tagName.toLowerCase()).toBe('div'); + expect(element.html()).toBe('baz'); + }); + }); + + describe('$formatter', function() { + var ngModel, + date; + + beforeEach(function() { + ngModel = element.controller('ngModel'); + date = new Date('Mon Mar 23 2015 14:40:11 GMT-0700 (PDT)'); + }); + + it('should have one formatter', function() { + expect(ngModel.$formatters.length).toBe(1); + }); + + it('should convert a date to a new reference representing the same date', function() { + expect(ngModel.$formatters[0](date)).toEqual(date); + }); + + it('should convert a valid date string to a date object', function() { + expect(ngModel.$formatters[0]('Mon Mar 23 2015 14:40:11 GMT-0700 (PDT)')).toEqual(date); + }); - expect(getTimeState()).toEqual(['02', '40', 'π.μ.']); - expect(getModelState()).toEqual([2, 40]); + it('should set falsy values as null', function() { + expect(ngModel.$formatters[0](undefined)).toBe(null); + expect(ngModel.$formatters[0](null)).toBe(null); + expect(ngModel.$formatters[0]('')).toBe(null); + expect(ngModel.$formatters[0](0)).toBe(null); + expect(ngModel.$formatters[0](NaN)).toBe(null); }); }); - describe('user input validation', function () { + describe('user input validation', function() { var changeInputValueTo; beforeEach(inject(function($sniffer) { - changeInputValueTo = function (inputEl, value) { + changeInputValueTo = function(inputEl, value) { inputEl.val(value); inputEl.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); $rootScope.$digest(); @@ -672,33 +1179,49 @@ describe('timepicker directive', function () { return element.find('input').eq(1); } + function getSecondsInputEl() { + return element.find('input').eq(2); + } + it('has initially the correct time & meridian', function() { - expect(getTimeState()).toEqual(['02', '40', 'PM']); - expect(getModelState()).toEqual([14, 40]); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); }); it('updates hours & pads on input change & pads on blur', function() { var el = getHoursInputEl(); changeInputValueTo(el, 5); - expect(getTimeState()).toEqual(['5', '40', 'PM']); - expect(getModelState()).toEqual([17, 40]); + expect(getTimeState()).toEqual(['5', '40', '25', 'PM']); + expect(getModelState()).toEqual([17, 40, 25]); el.blur(); - expect(getTimeState()).toEqual(['05', '40', 'PM']); - expect(getModelState()).toEqual([17, 40]); + expect(getTimeState()).toEqual(['05', '40', '25', 'PM']); + expect(getModelState()).toEqual([17, 40, 25]); }); it('updates minutes & pads on input change & pads on blur', function() { var el = getMinutesInputEl(); changeInputValueTo(el, 9); - expect(getTimeState()).toEqual(['02', '9', 'PM']); - expect(getModelState()).toEqual([14, 9]); + expect(getTimeState()).toEqual(['02', '9', '25', 'PM']); + expect(getModelState()).toEqual([14, 9, 25]); + + el.blur(); + expect(getTimeState()).toEqual(['02', '09', '25', 'PM']); + expect(getModelState()).toEqual([14, 9, 25]); + }); + + it('updates seconds & pads on input change & pads on blur', function() { + var el = getSecondsInputEl(); + + changeInputValueTo(el, 4); + expect(getTimeState()).toEqual(['02', '40', '4', 'PM']); + expect(getModelState()).toEqual([14, 40, 4]); el.blur(); - expect(getTimeState()).toEqual(['02', '09', 'PM']); - expect(getModelState()).toEqual([14, 9]); + expect(getTimeState()).toEqual(['02', '40', '04', 'PM']); + expect(getModelState()).toEqual([14, 40, 4]); }); it('clears model when input hours is invalid & alerts the UI', function() { @@ -706,75 +1229,166 @@ describe('timepicker directive', function () { changeInputValueTo(el, 'pizza'); expect($rootScope.time).toBe(null); - expect(el.parent().hasClass('error')).toBe(true); + expect(el.parent().hasClass('has-error')).toBe(true); + expect(el.hasClass('ng-invalid-hours')); expect(element.hasClass('ng-invalid-time')).toBe(true); changeInputValueTo(el, 8); el.blur(); $rootScope.$digest(); - expect(getTimeState()).toEqual(['08', '40', 'PM']); - expect(getModelState()).toEqual([20, 40]); - expect(el.parent().hasClass('error')).toBe(false); + expect(getTimeState()).toEqual(['08', '40', '25', 'PM']); + expect(getModelState()).toEqual([20, 40, 25]); + expect(el.parent().hasClass('has-error')).toBe(false); expect(element.hasClass('ng-invalid-time')).toBe(false); }); it('clears model when input minutes is invalid & alerts the UI', function() { var el = getMinutesInputEl(); - changeInputValueTo(el, 'pizza'); + changeInputValueTo(el, '8a'); expect($rootScope.time).toBe(null); - expect(el.parent().hasClass('error')).toBe(true); + expect(el.parent().hasClass('has-error')).toBe(true); + expect(el.hasClass('ng-invalid-minutes')); expect(element.hasClass('ng-invalid-time')).toBe(true); changeInputValueTo(el, 22); - expect(getTimeState()).toEqual(['02', '22', 'PM']); - expect(getModelState()).toEqual([14, 22]); - expect(el.parent().hasClass('error')).toBe(false); + expect(getTimeState()).toEqual(['02', '22', '25', 'PM']); + expect(getModelState()).toEqual([14, 22, 25]); + expect(el.parent().hasClass('has-error')).toBe(false); expect(element.hasClass('ng-invalid-time')).toBe(false); }); - it('handles 12/24H mode change', function() { - $rootScope.meridian = true; - element = $compile('')($rootScope); - $rootScope.$digest(); - - var el = getHoursInputEl(); + it('clears model when input seconds is invalid & alerts the UI', function() { + var el = getSecondsInputEl(); - changeInputValueTo(el, '16'); + changeInputValueTo(el, 'pizza'); expect($rootScope.time).toBe(null); - expect(el.parent().hasClass('error')).toBe(true); + expect(el.parent().hasClass('has-error')).toBe(true); + expect(el.hasClass('ng-invalid-seconds')); expect(element.hasClass('ng-invalid-time')).toBe(true); - $rootScope.meridian = false; - $rootScope.$digest(); - expect(getTimeState(true)).toEqual(['16', '40']); - expect(getModelState()).toEqual([16, 40]); + changeInputValueTo(el, 13); + expect(getTimeState()).toEqual(['02', '40', '13', 'PM']); + expect(getModelState()).toEqual([14, 40, 13]); + expect(el.parent().hasClass('has-error')).toBe(false); expect(element.hasClass('ng-invalid-time')).toBe(false); }); - }); - describe('when model is not a Date', function() { - beforeEach(inject(function() { - eelement = $compile('')($rootScope); - })); + it('should not be invalid when the model is cleared', function() { + var elH = getHoursInputEl(); + var elM = getMinutesInputEl(); + var elS = getSecondsInputEl(); - it('should not be invalid when the model is null', function() { - $rootScope.time = null; + $rootScope.time = newTime(10, 20, 30); $rootScope.$digest(); - expect(element.hasClass('ng-invalid-time')).toBe(false); - }); - it('should not be invalid when the model is undefined', function() { - $rootScope.time = undefined; + expect(getModelState()).toEqual([10, 20, 30]); + + changeInputValueTo(elH, ''); + elH.blur(); $rootScope.$digest(); + changeInputValueTo(elM, ''); + elM.blur(); + $rootScope.$digest(); + changeInputValueTo(elS, ''); + elS.blur(); + $rootScope.$digest(); + + expect(elH.hasClass('ng-valid')); + expect(elM.hasClass('ng-valid')); + expect(elS.hasClass('ng-valid')); expect(element.hasClass('ng-invalid-time')).toBe(false); }); - it('should not be invalid when the model is a valid string date representation', function() { - $rootScope.time = 'September 30, 2010 15:30:00'; + it('timepicker1 leaves view alone when hours are invalid and minutes are updated', function() { + var hoursEl = getHoursInputEl(), + minutesEl = getMinutesInputEl(); + + changeInputValueTo(hoursEl, '25'); + hoursEl.blur(); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['25', '40', '25', 'PM']); + + changeInputValueTo(minutesEl, '2'); + minutesEl.blur(); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['25', '2', '25', 'PM']); + }); + + it('leaves view alone when minutes are invalid and hours are updated', function() { + var hoursEl = getHoursInputEl(), + minutesEl = getMinutesInputEl(); + + changeInputValueTo(minutesEl, '61'); + minutesEl.blur(); + $rootScope.$digest(); + expect($rootScope.time).toBe(null); + expect(getTimeState()).toEqual(['02', '61', '25', 'PM']); + + changeInputValueTo(hoursEl, '2'); + hoursEl.blur(); + $rootScope.$digest(); + expect($rootScope.time).toBe(null); + expect(getTimeState()).toEqual(['2', '61', '25', 'PM']); + }); + + it('handles 12/24H mode change', function() { + $rootScope.meridian = true; + element = $compile('')($rootScope); $rootScope.$digest(); + + var el = getHoursInputEl(); + + changeInputValueTo(el, '16'); + expect($rootScope.time).toBe(null); + expect(el.parent().hasClass('has-error')).toBe(true); + expect(element.hasClass('ng-invalid-time')).toBe(true); + + $rootScope.meridian = false; + $rootScope.$digest(); + expect(getTimeState(true)).toEqual(['16', '40', '25']); + expect(getModelState()).toEqual([16, 40, 25]); expect(element.hasClass('ng-invalid-time')).toBe(false); - expect(getTimeState()).toEqual(['03', '30', 'PM']); + }); + + it('should have a default tabindex of 0', function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(element.isolateScope().tabindex).toBe(0); + }); + + it('should have the correct tabindex', function() { + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(element.attr('tabindex')).toBe(undefined); + expect(element.isolateScope().tabindex).toBe('5'); + }); + }); + + describe('when model is not a Date', function() { + beforeEach(inject(function() { + element = $compile('')($rootScope); + })); + + it('should not be invalid when the model is null', function() { + $rootScope.time = null; + $rootScope.$digest(); + expect(element.hasClass('ng-invalid-time')).toBe(false); + }); + + it('should not be invalid when the model is undefined', function() { + $rootScope.time = undefined; + $rootScope.$digest(); + expect(element.hasClass('ng-invalid-time')).toBe(false); + }); + + it('should not be invalid when the model is a valid string date representation', function() { + $rootScope.time = 'September 30, 2010 15:30:10'; + $rootScope.$digest(); + expect(element.hasClass('ng-invalid-time')).toBe(false); + expect(getTimeState()).toEqual(['03', '30', '10','PM']); }); it('should be invalid when the model is not a valid string date representation', function() { @@ -807,7 +1421,7 @@ describe('timepicker directive', function () { describe('use with `ng-required` directive', function() { beforeEach(inject(function() { $rootScope.time = null; - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); })); @@ -826,7 +1440,7 @@ describe('timepicker directive', function () { beforeEach(inject(function() { $rootScope.changeHandler = jasmine.createSpy('changeHandler'); $rootScope.time = new Date(); - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); })); @@ -837,11 +1451,14 @@ describe('timepicker directive', function () { it('should be called when hours / minutes buttons clicked', function() { var btn1 = getHoursButton(true); var btn2 = getMinutesButton(false); + var btn3 = getSecondsButton(false); doClick(btn1, 2); doClick(btn2, 3); + doClick(btn3, 1); $rootScope.$digest(); - expect($rootScope.changeHandler.callCount).toBe(5); + + expect($rootScope.changeHandler.calls.count()).toBe(6); }); it('should not be called when model changes programatically', function() { @@ -851,5 +1468,804 @@ describe('timepicker directive', function () { }); }); -}); + describe('when used with min', function() { + var changeInputValueTo; + beforeEach(inject(function($sniffer) { + element = $compile('')($rootScope); + $rootScope.$digest(); + changeInputValueTo = function(inputEl, value) { + inputEl.val(value); + inputEl.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); + $rootScope.$digest(); + }; + })); + + it('should not decrease hours when it would result in a time earlier than min', function() { + var down = getHoursButton(false); + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + var downMouseWheelEvent = wheelThatMouse(-1); + var downKeydownEvent = keydown('down'); + + $rootScope.min = newTime(13, 41); + $rootScope.$digest(); + + expect(down.hasClass('disabled')).toBe(true); + doClick(down); + expect(getTimeState(false,true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + + hoursEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + + hoursEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + }); + + it('should decrease hours when it would not result in a time earlier than min', function() { + var down = getHoursButton(false); + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + var downMouseWheelEvent = wheelThatMouse(-1); + var downKeydownEvent = keydown('down'); + + $rootScope.min = newTime(0, 0); + $rootScope.$digest(); + + expect(down.hasClass('disabled')).toBe(false); + + doClick(down); + expect(getTimeState(false, true)).toEqual(['01', '40', 'PM']); + expect(getModelState(true)).toEqual([13, 40]); + + hoursEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['12', '40', 'PM']); + expect(getModelState(true)).toEqual([12, 40]); + + hoursEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['11', '40', 'AM']); + expect(getModelState(true)).toEqual([11, 40]); + }); + + it('should not decrease minutes when it would result in a time ealier than min', function() { + var down = getMinutesButton(false); + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + var downMouseWheelEvent = wheelThatMouse(-1); + var downKeydownEvent = keydown('down'); + + $rootScope.min = newTime(14, 40); + $rootScope.$digest(); + + expect(down.hasClass('disabled')).toBe(true); + + doClick(down); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + + minutesEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + + minutesEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + }); + + it('should decrease minutes when it would not result in a time ealier than min', function() { + var down = getMinutesButton(false); + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + var downMouseWheelEvent = wheelThatMouse(-1); + var downKeydownEvent = keydown('down'); + + $rootScope.min = newTime(0, 0); + $rootScope.$digest(); + + expect(down.hasClass('disabled')).toBe(false); + + doClick(down); + expect(getTimeState(false, true)).toEqual(['02', '39', 'PM']); + expect(getModelState(true)).toEqual([14, 39]); + + minutesEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '38', 'PM']); + expect(getModelState(true)).toEqual([14, 38]); + + minutesEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '37', 'PM']); + expect(getModelState(true)).toEqual([14, 37]); + }); + + it('should not increase hours when time would rollover to a time earlier than min', function() { + var up = getHoursButton(true); + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + var upMouseWheelEvent = wheelThatMouse(1); + var upKeydownEvent = keydown('up'); + + $rootScope.time = newTime(23, 59); + $rootScope.min = newTime(13, 40); + $rootScope.$digest(); + + expect(up.hasClass('disabled')).toBe(true); + + doClick(up); + expect(getTimeState(false, true)).toEqual(['11', '59', 'PM']); + expect(getModelState(true)).toEqual([23, 59]); + + hoursEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['11', '59', 'PM']); + expect(getModelState(true)).toEqual([23, 59]); + + hoursEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['11', '59', 'PM']); + expect(getModelState(true)).toEqual([23, 59]); + }); + + it('should increase hours when time would rollover to a time not earlier than min', function() { + var up = getHoursButton(true); + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + var upMouseWheelEvent = wheelThatMouse(1); + var upKeydownEvent = keydown('up'); + + $rootScope.min = newTime(0, 0); + + $rootScope.time = newTime(23, 59); + $rootScope.$digest(); + + expect(up.hasClass('disabled')).toBe(false); + + doClick(up); + expect(getTimeState(false, true)).toEqual(['12', '59', 'AM']); + expect(getModelState(true)).toEqual([0, 59]); + + $rootScope.time = newTime(23, 59); + $rootScope.$digest(); + + hoursEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['12', '59', 'AM']); + expect(getModelState(true)).toEqual([0, 59]); + + $rootScope.time = newTime(23, 59); + $rootScope.$digest(); + + hoursEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['12', '59', 'AM']); + expect(getModelState(true)).toEqual([0, 59]); + }); + + + it('should not increase minutes when time would rollover to a time earlier than min', function() { + var up = getMinutesButton(true); + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + var upMouseWheelEvent = wheelThatMouse(1); + var upKeydownEvent = keydown('up'); + + $rootScope.time = newTime(23, 59); + $rootScope.min = newTime(13, 40); + $rootScope.$digest(); + + expect(up.hasClass('disabled')).toBe(true); + + doClick(up); + expect(getTimeState(false, true)).toEqual(['11', '59', 'PM']); + expect(getModelState(true)).toEqual([23, 59]); + + minutesEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['11', '59', 'PM']); + expect(getModelState(true)).toEqual([23, 59]); + + minutesEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['11', '59', 'PM']); + expect(getModelState(true)).toEqual([23, 59]); + }); + + it('should increase minutes when time would rollover to a time not earlier than min', function() { + var up = getMinutesButton(true); + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + var upMouseWheelEvent = wheelThatMouse(1); + var upKeydownEvent = keydown('up'); + + $rootScope.min = newTime(0, 0); + + $rootScope.time = newTime(23, 59); + $rootScope.$digest(); + + expect(up.hasClass('disabled')).toBe(false); + + doClick(up); + expect(getTimeState(false, true)).toEqual(['12', '00', 'AM']); + expect(getModelState(true)).toEqual([0, 0]); + + $rootScope.time = newTime(23, 59); + $rootScope.$digest(); + + minutesEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['12', '00', 'AM']); + expect(getModelState(true)).toEqual([0, 0]); + + $rootScope.time = newTime(23, 59); + $rootScope.$digest(); + + minutesEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['12', '00', 'AM']); + expect(getModelState(true)).toEqual([0, 0]); + }); + + it('should not change meridian when it would result a in time earlier than min', function() { + var button = getMeridianButton(); + + $rootScope.min = newTime(2, 41); + $rootScope.$digest(); + + expect(button.hasClass('disabled')).toBe(true); + + doClick(button); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + }); + + it('should change meridian when it would not result in a time earlier than min', function() { + var button = getMeridianButton(); + + $rootScope.min = newTime(2, 39); + $rootScope.$digest(); + + expect(button.hasClass('disabled')).toBe(false); + + doClick(button); + expect(getTimeState(false, true)).toEqual(['02', '40', 'AM']); + expect(getModelState(true)).toEqual([2, 40]); + }); + + it('should return invalid when the hours are changes such that the time is earlier than min', function() { + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + + $rootScope.min = newTime(14, 0); + $rootScope.$digest(); + + changeInputValueTo(hoursEl, 1); + expect($rootScope.time).toBe(null); + expect(hoursEl.parent().hasClass('has-error')).toBe(true); + expect(element.hasClass('ng-invalid-time')).toBe(true); + }); + + it('should return valid when the hours are changes such that the time is not earlier than min', function() { + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + + $rootScope.min = newTime(14, 41); + $rootScope.$digest(); + + changeInputValueTo(hoursEl, 3); + expect(getTimeState(false, true)).toEqual(['3', '40', 'PM']); + expect(getModelState(true)).toEqual([15, 40]); + expect(hoursEl.parent().hasClass('has-error')).toBe(false); + expect(element.hasClass('ng-invalid-time')).toBe(false); + }); + + it('should return invalid when the minutes are changes such that the time is earlier than min', function() { + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + + $rootScope.min = newTime(14, 30); + $rootScope.$digest(); + + changeInputValueTo(minutesEl, 1); + expect($rootScope.time).toBe(null); + expect(minutesEl.parent().hasClass('has-error')).toBe(true); + expect(element.hasClass('ng-invalid-time')).toBe(true); + }); + + it('should return valid when the minutes are changes such that the time is not earlier than min', function() { + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + + $rootScope.min = newTime(14, 41); + $rootScope.$digest(); + + changeInputValueTo(minutesEl, 42); + expect(getTimeState(false, true)).toEqual(['02', '42', 'PM']); + expect(getModelState(true)).toEqual([14, 42]); + expect(minutesEl.parent().hasClass('has-error')).toBe(false); + expect(element.hasClass('ng-invalid-time')).toBe(false); + }); + }); + + describe('when used with max', function() { + var changeInputValueTo; + beforeEach(inject(function($sniffer) { + element = $compile('')($rootScope); + $rootScope.$digest(); + changeInputValueTo = function(inputEl, value) { + inputEl.val(value); + inputEl.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); + $rootScope.$digest(); + }; + })); + + it('should not increase hours when it would result in a time later than max', function() { + var up = getHoursButton(true); + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + var upMouseWheelEvent = wheelThatMouse(1); + var upKeydownEvent = keydown('up'); + + $rootScope.max = newTime(15, 39); + $rootScope.$digest(); + + expect(up.hasClass('disabled')).toBe(true); + + doClick(up); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + + hoursEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + + hoursEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + }); + + it('should increase hours when it would not result in a time later than max', function() { + var up = getHoursButton(true); + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + var upMouseWheelEvent = wheelThatMouse(1); + var upKeydownEvent = keydown('up'); + + $rootScope.max = newTime(23, 59); + $rootScope.$digest(); + + expect(up.hasClass('disabled')).toBe(false); + + doClick(up); + expect(getTimeState(false, true)).toEqual(['03', '40', 'PM']); + expect(getModelState(true)).toEqual([15, 40]); + + hoursEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['04', '40', 'PM']); + expect(getModelState(true)).toEqual([16, 40]); + + hoursEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['05', '40', 'PM']); + expect(getModelState(true)).toEqual([17, 40]); + }); + + it('should not increase minutes when it would result in a time later than max', function() { + var up = getMinutesButton(true); + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + var upMouseWheelEvent = wheelThatMouse(1); + var upKeydownEvent = keydown('up'); + + $rootScope.max = newTime(14, 40); + $rootScope.$digest(); + + expect(up.hasClass('disabled')).toBe(true); + + doClick(up); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + + minutesEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + + minutesEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + }); + + it('should increase minutes when it would not result in a time later than max', function() { + var up = getMinutesButton(true); + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + var upMouseWheelEvent = wheelThatMouse(1); + var upKeydownEvent = keydown('up'); + + $rootScope.max = newTime(23, 59); + $rootScope.$digest(); + + expect(up.hasClass('disabled')).toBe(false); + + doClick(up); + expect(getTimeState(false, true)).toEqual(['02', '41', 'PM']); + expect(getModelState(true)).toEqual([14, 41]); + + minutesEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '42', 'PM']); + expect(getModelState(true)).toEqual([14, 42]); + + minutesEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['02', '43', 'PM']); + expect(getModelState(true)).toEqual([14, 43]); + }); + + it('should not decrease hours when time would rollover to a time later than max', function() { + var down = getHoursButton(false); + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + var downMouseWheelEvent = wheelThatMouse(-1); + var downKeydownEvent = keydown('down'); + + $rootScope.time = newTime(0, 0); + $rootScope.max = newTime(13, 40); + $rootScope.$digest(); + + expect(down.hasClass('disabled')).toBe(true); + + doClick(down); + expect(getTimeState(false, true)).toEqual(['12', '00', 'AM']); + expect(getModelState(true)).toEqual([0, 0]); + + hoursEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['12', '00', 'AM']); + expect(getModelState(true)).toEqual([0, 0]); + + hoursEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['12', '00', 'AM']); + expect(getModelState(true)).toEqual([0, 0]); + }); + it('should decrease hours when time would rollover to a time not later than max', function() { + var down = getHoursButton(false); + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + var downMouseWheelEvent = wheelThatMouse(-1); + var downKeydownEvent = keydown('down'); + + $rootScope.max = newTime(23, 59); + + $rootScope.time = newTime(0, 0); + $rootScope.$digest(); + + expect(down.hasClass('disabled')).toBe(false); + + doClick(down); + expect(getTimeState(false, true)).toEqual(['11', '00', 'PM']); + expect(getModelState(true)).toEqual([23, 0]); + + $rootScope.time = newTime(0, 0); + $rootScope.$digest(); + + hoursEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['11', '00', 'PM']); + expect(getModelState(true)).toEqual([23, 0]); + + $rootScope.time = newTime(0, 0); + $rootScope.$digest(); + + hoursEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['11', '00', 'PM']); + expect(getModelState(true)).toEqual([23, 0]); + }); + + it('should not decrease minutes when time would rollover to a time later than max', function() { + var down = getMinutesButton(false); + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + var downMouseWheelEvent = wheelThatMouse(-1); + var downKeydownEvent = keydown('down'); + + $rootScope.time = newTime(0, 0); + $rootScope.max = newTime(13, 40); + $rootScope.$digest(); + + expect(down.hasClass('disabled')).toBe(true); + + doClick(down); + expect(getTimeState(false, true)).toEqual(['12', '00', 'AM']); + expect(getModelState(true)).toEqual([0, 0]); + + minutesEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['12', '00', 'AM']); + expect(getModelState(true)).toEqual([0, 0]); + + minutesEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['12', '00', 'AM']); + expect(getModelState(true)).toEqual([0, 0]); + }); + + it('should decrease minutes when time would rollover to a time not later than max', function() { + var down = getMinutesButton(false); + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + var downMouseWheelEvent = wheelThatMouse(-1); + var downKeydownEvent = keydown('down'); + + $rootScope.max = newTime(23, 59); + + $rootScope.time = newTime(0, 0); + $rootScope.$digest(); + + expect(down.hasClass('disabled')).toBe(false); + + doClick(down); + expect(getTimeState(false, true)).toEqual(['11', '59', 'PM']); + expect(getModelState(true)).toEqual([23, 59]); + + $rootScope.time = newTime(0, 0); + $rootScope.$digest(); + minutesEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['11', '59', 'PM']); + expect(getModelState(true)).toEqual([23, 59]); + + $rootScope.time = newTime(0, 0); + $rootScope.$digest(); + + minutesEl.trigger( downKeydownEvent ); + $rootScope.$digest(); + expect(getTimeState(false, true)).toEqual(['11', '59', 'PM']); + expect(getModelState(true)).toEqual([23, 59]); + }); + + it('should not change meridian when it would result a in time later than max', function() { + var button = getMeridianButton(); + + $rootScope.time = newTime(2, 40); + $rootScope.max = newTime(14, 39); + $rootScope.$digest(); + + expect(button.hasClass('disabled')).toBe(true); + + doClick(button); + expect(getTimeState(false, true)).toEqual(['02', '40', 'AM']); + expect(getModelState(true)).toEqual([2, 40]); + }); + + it('should change meridian when it would not result in a time later than max', function() { + var button = getMeridianButton(); + + $rootScope.time = newTime(2, 40); + $rootScope.max = newTime(14, 41); + $rootScope.$digest(); + + expect(button.hasClass('disabled')).toBe(false); + + doClick(button); + expect(getTimeState(false, true)).toEqual(['02', '40', 'PM']); + expect(getModelState(true)).toEqual([14, 40]); + }); + + it('should return invalid when the hours are changes such that the time is later than max', function() { + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + + $rootScope.max = newTime(14, 0); + $rootScope.$digest(); + + changeInputValueTo(hoursEl, 3); + expect($rootScope.time).toBe(null); + expect(hoursEl.parent().hasClass('has-error')).toBe(true); + expect(element.hasClass('ng-invalid-time')).toBe(true); + }); + + it('should return valid when the hours are changes such that the time is not later than max', function() { + var inputs = element.find('input'); + var hoursEl = inputs.eq(0); + + $rootScope.max = newTime(15, 41); + $rootScope.$digest(); + + changeInputValueTo(hoursEl, 3); + expect(getTimeState(false, true)).toEqual(['3', '40', 'PM']); + expect(getModelState(true)).toEqual([15, 40]); + expect(hoursEl.parent().hasClass('has-error')).toBe(false); + expect(element.hasClass('ng-invalid-time')).toBe(false); + }); + + it('should return invalid when the minutes are changes such that the time is later than max', function() { + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + + $rootScope.max = newTime(14, 50); + $rootScope.$digest(); + + changeInputValueTo(minutesEl, 51); + expect($rootScope.time).toBe(null); + expect(minutesEl.parent().hasClass('has-error')).toBe(true); + expect(element.hasClass('ng-invalid-time')).toBe(true); + }); + + it('should return valid when the minutes are changes such that the time is not later than max', function() { + var inputs = element.find('input'); + var minutesEl = inputs.eq(1); + + $rootScope.max = newTime(14, 42); + $rootScope.$digest(); + + changeInputValueTo(minutesEl, 41); + expect(getTimeState(false, true)).toEqual(['02', '41', 'PM']); + expect(getModelState(true)).toEqual([14, 41]); + expect(minutesEl.parent().hasClass('has-error')).toBe(false); + expect(element.hasClass('ng-invalid-time')).toBe(false); + }); + }); + + describe('custom template and controllerAs', function() { + it('should allow custom templates', function() { + $templateCache.put('foo/bar.html', 'baz'); + + element = $compile('')($rootScope); + $rootScope.$digest(); + expect(element[0].tagName.toLowerCase()).toBe('div'); + expect(element.html()).toBe('baz'); + }); + + it('should expose the controller on the view', function() { + $templateCache.put('uib/template/timepicker/timepicker.html', '{{timepicker.text}}'); + + element = $compile('')($rootScope); + $rootScope.$digest(); + + var ctrl = element.controller('uibTimepicker'); + expect(ctrl).toBeDefined(); + + ctrl.text = 'foo'; + $rootScope.$digest(); + + expect(element.html()).toBe('foo'); + }); + }); + + describe('ngDisabled', function() { + it('prevents modifying date via controls when true', function() { + $rootScope.disabled = false; + element = $compile('')($rootScope); + $rootScope.$digest(); + + var inputs = element.find('input'); + var hoursEl = inputs.eq(0), minutesEl = inputs.eq(1), + secondsEl = inputs.eq(2); + var upKeydownEvent = keydown('up'); + var downKeydownEvent = keydown('down'); + var leftKeydownEvent = keydown('left'); + var upMouseWheelEvent = wheelThatMouse(1); + var downMouseWheelEvent = wheelThatMouse(-1); + + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + $rootScope.disabled = true; + $rootScope.$digest(); + + // UP + hoursEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + minutesEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + secondsEl.trigger(upKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + // DOWN + secondsEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + minutesEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + hoursEl.trigger(downKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + // Other keydown + hoursEl.trigger(leftKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + minutesEl.trigger(leftKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + secondsEl.trigger(leftKeydownEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + // WHEEL UP + secondsEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + minutesEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + hoursEl.trigger(upMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + // WHEEL DOWN + secondsEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + minutesEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + + hoursEl.trigger(downMouseWheelEvent); + $rootScope.$digest(); + expect(getTimeState()).toEqual(['02', '40', '25', 'PM']); + expect(getModelState()).toEqual([14, 40, 25]); + }); + }); + + describe('gc', function() { + var $scope; + beforeEach(inject(function() { + $scope = $rootScope.$new(); + element = $compile('')($scope); + $rootScope.$digest(); + })); + + it('should clean up watchers', function() { + expect($scope.$$watchers.length > 1).toBe(true); + + element.isolateScope().$destroy(); + + expect($scope.$$watchers.length).toBe(1); + }); + }); +}); diff --git a/src/timepicker/timepicker.css b/src/timepicker/timepicker.css new file mode 100644 index 0000000000..53e788278e --- /dev/null +++ b/src/timepicker/timepicker.css @@ -0,0 +1,3 @@ +.uib-time input { + width: 50px; +} diff --git a/src/timepicker/timepicker.js b/src/timepicker/timepicker.js index 3e6f9064f6..4e77901cdd 100644 --- a/src/timepicker/timepicker.js +++ b/src/timepicker/timepicker.js @@ -1,232 +1,591 @@ angular.module('ui.bootstrap.timepicker', []) -.constant('timepickerConfig', { +.constant('uibTimepickerConfig', { hourStep: 1, minuteStep: 1, + secondStep: 1, showMeridian: true, - meridians: ['AM', 'PM'], + showSeconds: false, + meridians: null, readonlyInput: false, - mousewheel: true + mousewheel: true, + arrowkeys: true, + showSpinners: true, + templateUrl: 'uib/template/timepicker/timepicker.html' }) -.directive('timepicker', ['$parse', '$log', 'timepickerConfig', function ($parse, $log, timepickerConfig) { - return { - restrict: 'EA', - require:'?^ngModel', - replace: true, - scope: {}, - templateUrl: 'template/timepicker/timepicker.html', - link: function(scope, element, attrs, ngModel) { - if ( !ngModel ) { - return; // do nothing if no ng-model +.controller('UibTimepickerController', ['$scope', '$element', '$attrs', '$parse', '$log', '$locale', 'uibTimepickerConfig', function($scope, $element, $attrs, $parse, $log, $locale, timepickerConfig) { + var hoursModelCtrl, minutesModelCtrl, secondsModelCtrl; + var selected = new Date(), + watchers = [], + ngModelCtrl = { $setViewValue: angular.noop }, // nullModelCtrl + meridians = angular.isDefined($attrs.meridians) ? $scope.$parent.$eval($attrs.meridians) : timepickerConfig.meridians || $locale.DATETIME_FORMATS.AMPMS, + padHours = angular.isDefined($attrs.padHours) ? $scope.$parent.$eval($attrs.padHours) : true; + + $scope.tabindex = angular.isDefined($attrs.tabindex) ? $attrs.tabindex : 0; + $element.removeAttr('tabindex'); + + this.init = function(ngModelCtrl_, inputs) { + ngModelCtrl = ngModelCtrl_; + ngModelCtrl.$render = this.render; + + ngModelCtrl.$formatters.unshift(function(modelValue) { + return modelValue ? new Date(modelValue) : null; + }); + + var hoursInputEl = inputs.eq(0), + minutesInputEl = inputs.eq(1), + secondsInputEl = inputs.eq(2); + + hoursModelCtrl = hoursInputEl.controller('ngModel'); + minutesModelCtrl = minutesInputEl.controller('ngModel'); + secondsModelCtrl = secondsInputEl.controller('ngModel'); + + var mousewheel = angular.isDefined($attrs.mousewheel) ? $scope.$parent.$eval($attrs.mousewheel) : timepickerConfig.mousewheel; + + if (mousewheel) { + this.setupMousewheelEvents(hoursInputEl, minutesInputEl, secondsInputEl); + } + + var arrowkeys = angular.isDefined($attrs.arrowkeys) ? $scope.$parent.$eval($attrs.arrowkeys) : timepickerConfig.arrowkeys; + if (arrowkeys) { + this.setupArrowkeyEvents(hoursInputEl, minutesInputEl, secondsInputEl); + } + + $scope.readonlyInput = angular.isDefined($attrs.readonlyInput) ? $scope.$parent.$eval($attrs.readonlyInput) : timepickerConfig.readonlyInput; + this.setupInputEvents(hoursInputEl, minutesInputEl, secondsInputEl); + }; + + var hourStep = timepickerConfig.hourStep; + if ($attrs.hourStep) { + watchers.push($scope.$parent.$watch($parse($attrs.hourStep), function(value) { + hourStep = +value; + })); + } + + var minuteStep = timepickerConfig.minuteStep; + if ($attrs.minuteStep) { + watchers.push($scope.$parent.$watch($parse($attrs.minuteStep), function(value) { + minuteStep = +value; + })); + } + + var min; + watchers.push($scope.$parent.$watch($parse($attrs.min), function(value) { + var dt = new Date(value); + min = isNaN(dt) ? undefined : dt; + })); + + var max; + watchers.push($scope.$parent.$watch($parse($attrs.max), function(value) { + var dt = new Date(value); + max = isNaN(dt) ? undefined : dt; + })); + + var disabled = false; + if ($attrs.ngDisabled) { + watchers.push($scope.$parent.$watch($parse($attrs.ngDisabled), function(value) { + disabled = value; + })); + } + + $scope.noIncrementHours = function() { + var incrementedSelected = addMinutes(selected, hourStep * 60); + return disabled || incrementedSelected > max || + incrementedSelected < selected && incrementedSelected < min; + }; + + $scope.noDecrementHours = function() { + var decrementedSelected = addMinutes(selected, -hourStep * 60); + return disabled || decrementedSelected < min || + decrementedSelected > selected && decrementedSelected > max; + }; + + $scope.noIncrementMinutes = function() { + var incrementedSelected = addMinutes(selected, minuteStep); + return disabled || incrementedSelected > max || + incrementedSelected < selected && incrementedSelected < min; + }; + + $scope.noDecrementMinutes = function() { + var decrementedSelected = addMinutes(selected, -minuteStep); + return disabled || decrementedSelected < min || + decrementedSelected > selected && decrementedSelected > max; + }; + + $scope.noIncrementSeconds = function() { + var incrementedSelected = addSeconds(selected, secondStep); + return disabled || incrementedSelected > max || + incrementedSelected < selected && incrementedSelected < min; + }; + + $scope.noDecrementSeconds = function() { + var decrementedSelected = addSeconds(selected, -secondStep); + return disabled || decrementedSelected < min || + decrementedSelected > selected && decrementedSelected > max; + }; + + $scope.noToggleMeridian = function() { + if (selected.getHours() < 12) { + return disabled || addMinutes(selected, 12 * 60) > max; + } + + return disabled || addMinutes(selected, -12 * 60) < min; + }; + + var secondStep = timepickerConfig.secondStep; + if ($attrs.secondStep) { + watchers.push($scope.$parent.$watch($parse($attrs.secondStep), function(value) { + secondStep = +value; + })); + } + + $scope.showSeconds = timepickerConfig.showSeconds; + if ($attrs.showSeconds) { + watchers.push($scope.$parent.$watch($parse($attrs.showSeconds), function(value) { + $scope.showSeconds = !!value; + })); + } + + // 12H / 24H mode + $scope.showMeridian = timepickerConfig.showMeridian; + if ($attrs.showMeridian) { + watchers.push($scope.$parent.$watch($parse($attrs.showMeridian), function(value) { + $scope.showMeridian = !!value; + + if (ngModelCtrl.$error.time) { + // Evaluate from template + var hours = getHoursFromTemplate(), minutes = getMinutesFromTemplate(); + if (angular.isDefined(hours) && angular.isDefined(minutes)) { + selected.setHours(hours); + refresh(); + } + } else { + updateTemplate(); } + })); + } + + // Get $scope.hours in 24H mode if valid + function getHoursFromTemplate() { + var hours = +$scope.hours; + var valid = $scope.showMeridian ? hours > 0 && hours < 13 : + hours >= 0 && hours < 24; + if (!valid || $scope.hours === '') { + return undefined; + } - var selected = new Date(), meridians = timepickerConfig.meridians; + if ($scope.showMeridian) { + if (hours === 12) { + hours = 0; + } + if ($scope.meridian === meridians[1]) { + hours = hours + 12; + } + } + return hours; + } + + function getMinutesFromTemplate() { + var minutes = +$scope.minutes; + var valid = minutes >= 0 && minutes < 60; + if (!valid || $scope.minutes === '') { + return undefined; + } + return minutes; + } - var hourStep = timepickerConfig.hourStep; - if (attrs.hourStep) { - scope.$parent.$watch($parse(attrs.hourStep), function(value) { - hourStep = parseInt(value, 10); - }); + function getSecondsFromTemplate() { + var seconds = +$scope.seconds; + return seconds >= 0 && seconds < 60 ? seconds : undefined; + } + + function pad(value, noPad) { + if (value === null) { + return ''; + } + + return angular.isDefined(value) && value.toString().length < 2 && !noPad ? + '0' + value : value.toString(); + } + + // Respond on mousewheel spin + this.setupMousewheelEvents = function(hoursInputEl, minutesInputEl, secondsInputEl) { + var isScrollingUp = function(e) { + if (e.originalEvent) { + e = e.originalEvent; + } + //pick correct delta variable depending on event + var delta = e.wheelDelta ? e.wheelDelta : -e.deltaY; + return e.detail || delta > 0; + }; + + hoursInputEl.on('mousewheel wheel', function(e) { + if (!disabled) { + $scope.$apply(isScrollingUp(e) ? $scope.incrementHours() : $scope.decrementHours()); } + e.preventDefault(); + }); - var minuteStep = timepickerConfig.minuteStep; - if (attrs.minuteStep) { - scope.$parent.$watch($parse(attrs.minuteStep), function(value) { - minuteStep = parseInt(value, 10); - }); + minutesInputEl.on('mousewheel wheel', function(e) { + if (!disabled) { + $scope.$apply(isScrollingUp(e) ? $scope.incrementMinutes() : $scope.decrementMinutes()); } + e.preventDefault(); + }); - // 12H / 24H mode - scope.showMeridian = timepickerConfig.showMeridian; - if (attrs.showMeridian) { - scope.$parent.$watch($parse(attrs.showMeridian), function(value) { - scope.showMeridian = !!value; - - if ( ngModel.$error.time ) { - // Evaluate from template - var hours = getHoursFromTemplate(), minutes = getMinutesFromTemplate(); - if (angular.isDefined( hours ) && angular.isDefined( minutes )) { - selected.setHours( hours ); - refresh(); - } - } else { - updateTemplate(); - } - }); + secondsInputEl.on('mousewheel wheel', function(e) { + if (!disabled) { + $scope.$apply(isScrollingUp(e) ? $scope.incrementSeconds() : $scope.decrementSeconds()); } + e.preventDefault(); + }); + }; - // Get scope.hours in 24H mode if valid - function getHoursFromTemplate ( ) { - var hours = parseInt( scope.hours, 10 ); - var valid = ( scope.showMeridian ) ? (hours > 0 && hours < 13) : (hours >= 0 && hours < 24); - if ( !valid ) { - return undefined; + // Respond on up/down arrowkeys + this.setupArrowkeyEvents = function(hoursInputEl, minutesInputEl, secondsInputEl) { + hoursInputEl.on('keydown', function(e) { + if (!disabled) { + if (e.which === 38) { // up + e.preventDefault(); + $scope.incrementHours(); + $scope.$apply(); + } else if (e.which === 40) { // down + e.preventDefault(); + $scope.decrementHours(); + $scope.$apply(); } + } + }); - if ( scope.showMeridian ) { - if ( hours === 12 ) { - hours = 0; - } - if ( scope.meridian === meridians[1] ) { - hours = hours + 12; - } + minutesInputEl.on('keydown', function(e) { + if (!disabled) { + if (e.which === 38) { // up + e.preventDefault(); + $scope.incrementMinutes(); + $scope.$apply(); + } else if (e.which === 40) { // down + e.preventDefault(); + $scope.decrementMinutes(); + $scope.$apply(); } - return hours; } + }); - function getMinutesFromTemplate() { - var minutes = parseInt(scope.minutes, 10); - return ( minutes >= 0 && minutes < 60 ) ? minutes : undefined; + secondsInputEl.on('keydown', function(e) { + if (!disabled) { + if (e.which === 38) { // up + e.preventDefault(); + $scope.incrementSeconds(); + $scope.$apply(); + } else if (e.which === 40) { // down + e.preventDefault(); + $scope.decrementSeconds(); + $scope.$apply(); + } } + }); + }; - function pad( value ) { - return ( angular.isDefined(value) && value.toString().length < 2 ) ? '0' + value : value; + this.setupInputEvents = function(hoursInputEl, minutesInputEl, secondsInputEl) { + if ($scope.readonlyInput) { + $scope.updateHours = angular.noop; + $scope.updateMinutes = angular.noop; + $scope.updateSeconds = angular.noop; + return; + } + + var invalidate = function(invalidHours, invalidMinutes, invalidSeconds) { + ngModelCtrl.$setViewValue(null); + ngModelCtrl.$setValidity('time', false); + if (angular.isDefined(invalidHours)) { + $scope.invalidHours = invalidHours; + if (hoursModelCtrl) { + hoursModelCtrl.$setValidity('hours', false); + } } - // Input elements - var inputs = element.find('input'), hoursInputEl = inputs.eq(0), minutesInputEl = inputs.eq(1); + if (angular.isDefined(invalidMinutes)) { + $scope.invalidMinutes = invalidMinutes; + if (minutesModelCtrl) { + minutesModelCtrl.$setValidity('minutes', false); + } + } - // Respond on mousewheel spin - var mousewheel = (angular.isDefined(attrs.mousewheel)) ? scope.$eval(attrs.mousewheel) : timepickerConfig.mousewheel; - if ( mousewheel ) { + if (angular.isDefined(invalidSeconds)) { + $scope.invalidSeconds = invalidSeconds; + if (secondsModelCtrl) { + secondsModelCtrl.$setValidity('seconds', false); + } + } + }; - var isScrollingUp = function(e) { - if (e.originalEvent) { - e = e.originalEvent; - } - //pick correct delta variable depending on event - var delta = (e.wheelDelta) ? e.wheelDelta : -e.deltaY; - return (e.detail || delta > 0); - }; + $scope.updateHours = function() { + var hours = getHoursFromTemplate(), + minutes = getMinutesFromTemplate(); - hoursInputEl.bind('mousewheel wheel', function(e) { - scope.$apply( (isScrollingUp(e)) ? scope.incrementHours() : scope.decrementHours() ); - e.preventDefault(); - }); + ngModelCtrl.$setDirty(); - minutesInputEl.bind('mousewheel wheel', function(e) { - scope.$apply( (isScrollingUp(e)) ? scope.incrementMinutes() : scope.decrementMinutes() ); - e.preventDefault(); - }); + if (angular.isDefined(hours) && angular.isDefined(minutes)) { + selected.setHours(hours); + selected.setMinutes(minutes); + if (selected < min || selected > max) { + invalidate(true); + } else { + refresh('h'); + } + } else { + invalidate(true); } + }; - scope.readonlyInput = (angular.isDefined(attrs.readonlyInput)) ? scope.$eval(attrs.readonlyInput) : timepickerConfig.readonlyInput; - if ( ! scope.readonlyInput ) { - - var invalidate = function(invalidHours, invalidMinutes) { - ngModel.$setViewValue( null ); - ngModel.$setValidity('time', false); - if (angular.isDefined(invalidHours)) { - scope.invalidHours = invalidHours; - } - if (angular.isDefined(invalidMinutes)) { - scope.invalidMinutes = invalidMinutes; - } - }; - - scope.updateHours = function() { - var hours = getHoursFromTemplate(); - - if ( angular.isDefined(hours) ) { - selected.setHours( hours ); - refresh( 'h' ); - } else { - invalidate(true); - } - }; - - hoursInputEl.bind('blur', function(e) { - if ( !scope.validHours && scope.hours < 10) { - scope.$apply( function() { - scope.hours = pad( scope.hours ); - }); - } + hoursInputEl.on('blur', function(e) { + ngModelCtrl.$setTouched(); + if (modelIsEmpty()) { + makeValid(); + } else if ($scope.hours === null || $scope.hours === '') { + invalidate(true); + } else if (!$scope.invalidHours && $scope.hours < 10) { + $scope.$apply(function() { + $scope.hours = pad($scope.hours, !padHours); }); + } + }); + + $scope.updateMinutes = function() { + var minutes = getMinutesFromTemplate(), + hours = getHoursFromTemplate(); + + ngModelCtrl.$setDirty(); + + if (angular.isDefined(minutes) && angular.isDefined(hours)) { + selected.setHours(hours); + selected.setMinutes(minutes); + if (selected < min || selected > max) { + invalidate(undefined, true); + } else { + refresh('m'); + } + } else { + invalidate(undefined, true); + } + }; - scope.updateMinutes = function() { - var minutes = getMinutesFromTemplate(); - - if ( angular.isDefined(minutes) ) { - selected.setMinutes( minutes ); - refresh( 'm' ); - } else { - invalidate(undefined, true); - } - }; - - minutesInputEl.bind('blur', function(e) { - if ( !scope.invalidMinutes && scope.minutes < 10 ) { - scope.$apply( function() { - scope.minutes = pad( scope.minutes ); - }); - } + minutesInputEl.on('blur', function(e) { + ngModelCtrl.$setTouched(); + if (modelIsEmpty()) { + makeValid(); + } else if ($scope.minutes === null) { + invalidate(undefined, true); + } else if (!$scope.invalidMinutes && $scope.minutes < 10) { + $scope.$apply(function() { + $scope.minutes = pad($scope.minutes); }); + } + }); + + $scope.updateSeconds = function() { + var seconds = getSecondsFromTemplate(); + + ngModelCtrl.$setDirty(); + + if (angular.isDefined(seconds)) { + selected.setSeconds(seconds); + refresh('s'); } else { - scope.updateHours = angular.noop; - scope.updateMinutes = angular.noop; + invalidate(undefined, undefined, true); } + }; - ngModel.$render = function() { - var date = ngModel.$modelValue ? new Date( ngModel.$modelValue ) : null; + secondsInputEl.on('blur', function(e) { + if (modelIsEmpty()) { + makeValid(); + } else if (!$scope.invalidSeconds && $scope.seconds < 10) { + $scope.$apply( function() { + $scope.seconds = pad($scope.seconds); + }); + } + }); - if ( isNaN(date) ) { - ngModel.$setValidity('time', false); - $log.error('Timepicker directive: "ng-model" value must be a Date object, a number of milliseconds since 01.01.1970 or a string representing an RFC2822 or ISO 8601 date.'); - } else { - if ( date ) { - selected = date; - } - makeValid(); - updateTemplate(); - } - }; + }; - // Call internally when we know that model is valid. - function refresh( keyboardChange ) { + this.render = function() { + var date = ngModelCtrl.$viewValue; + + if (isNaN(date)) { + ngModelCtrl.$setValidity('time', false); + $log.error('Timepicker directive: "ng-model" value must be a Date object, a number of milliseconds since 01.01.1970 or a string representing an RFC2822 or ISO 8601 date.'); + } else { + if (date) { + selected = date; + } + + if (selected < min || selected > max) { + ngModelCtrl.$setValidity('time', false); + $scope.invalidHours = true; + $scope.invalidMinutes = true; + } else { makeValid(); - ngModel.$setViewValue( new Date(selected) ); - updateTemplate( keyboardChange ); } + updateTemplate(); + } + }; - function makeValid() { - ngModel.$setValidity('time', true); - scope.invalidHours = false; - scope.invalidMinutes = false; + // Call internally when we know that model is valid. + function refresh(keyboardChange) { + makeValid(); + ngModelCtrl.$setViewValue(new Date(selected)); + updateTemplate(keyboardChange); + } + + function makeValid() { + if (hoursModelCtrl) { + hoursModelCtrl.$setValidity('hours', true); + } + + if (minutesModelCtrl) { + minutesModelCtrl.$setValidity('minutes', true); + } + + if (secondsModelCtrl) { + secondsModelCtrl.$setValidity('seconds', true); + } + + ngModelCtrl.$setValidity('time', true); + $scope.invalidHours = false; + $scope.invalidMinutes = false; + $scope.invalidSeconds = false; + } + + function updateTemplate(keyboardChange) { + if (!ngModelCtrl.$modelValue) { + $scope.hours = null; + $scope.minutes = null; + $scope.seconds = null; + $scope.meridian = meridians[0]; + } else { + var hours = selected.getHours(), + minutes = selected.getMinutes(), + seconds = selected.getSeconds(); + + if ($scope.showMeridian) { + hours = hours === 0 || hours === 12 ? 12 : hours % 12; // Convert 24 to 12 hour system } - function updateTemplate( keyboardChange ) { - var hours = selected.getHours(), minutes = selected.getMinutes(); + $scope.hours = keyboardChange === 'h' ? hours : pad(hours, !padHours); + if (keyboardChange !== 'm') { + $scope.minutes = pad(minutes); + } + $scope.meridian = selected.getHours() < 12 ? meridians[0] : meridians[1]; - if ( scope.showMeridian ) { - hours = ( hours === 0 || hours === 12 ) ? 12 : hours % 12; // Convert 24 to 12 hour system - } - scope.hours = keyboardChange === 'h' ? hours : pad(hours); - scope.minutes = keyboardChange === 'm' ? minutes : pad(minutes); - scope.meridian = selected.getHours() < 12 ? meridians[0] : meridians[1]; - } - - function addMinutes( minutes ) { - var dt = new Date( selected.getTime() + minutes * 60000 ); - selected.setHours( dt.getHours(), dt.getMinutes() ); - refresh(); - } - - scope.incrementHours = function() { - addMinutes( hourStep * 60 ); - }; - scope.decrementHours = function() { - addMinutes( - hourStep * 60 ); - }; - scope.incrementMinutes = function() { - addMinutes( minuteStep ); - }; - scope.decrementMinutes = function() { - addMinutes( - minuteStep ); - }; - scope.toggleMeridian = function() { - addMinutes( 12 * 60 * (( selected.getHours() < 12 ) ? 1 : -1) ); - }; + if (keyboardChange !== 's') { + $scope.seconds = pad(seconds); + } + $scope.meridian = selected.getHours() < 12 ? meridians[0] : meridians[1]; + } + } + + function addSecondsToSelected(seconds) { + selected = addSeconds(selected, seconds); + refresh(); + } + + function addMinutes(selected, minutes) { + return addSeconds(selected, minutes*60); + } + + function addSeconds(date, seconds) { + var dt = new Date(date.getTime() + seconds * 1000); + var newDate = new Date(date); + newDate.setHours(dt.getHours(), dt.getMinutes(), dt.getSeconds()); + return newDate; + } + + function modelIsEmpty() { + return ($scope.hours === null || $scope.hours === '') && + ($scope.minutes === null || $scope.minutes === '') && + (!$scope.showSeconds || $scope.showSeconds && ($scope.seconds === null || $scope.seconds === '')); + } + + $scope.showSpinners = angular.isDefined($attrs.showSpinners) ? + $scope.$parent.$eval($attrs.showSpinners) : timepickerConfig.showSpinners; + + $scope.incrementHours = function() { + if (!$scope.noIncrementHours()) { + addSecondsToSelected(hourStep * 60 * 60); + } + }; + + $scope.decrementHours = function() { + if (!$scope.noDecrementHours()) { + addSecondsToSelected(-hourStep * 60 * 60); + } + }; + + $scope.incrementMinutes = function() { + if (!$scope.noIncrementMinutes()) { + addSecondsToSelected(minuteStep * 60); + } + }; + + $scope.decrementMinutes = function() { + if (!$scope.noDecrementMinutes()) { + addSecondsToSelected(-minuteStep * 60); + } + }; + + $scope.incrementSeconds = function() { + if (!$scope.noIncrementSeconds()) { + addSecondsToSelected(secondStep); + } + }; + + $scope.decrementSeconds = function() { + if (!$scope.noDecrementSeconds()) { + addSecondsToSelected(-secondStep); + } + }; + + $scope.toggleMeridian = function() { + var minutes = getMinutesFromTemplate(), + hours = getHoursFromTemplate(); + + if (!$scope.noToggleMeridian()) { + if (angular.isDefined(minutes) && angular.isDefined(hours)) { + addSecondsToSelected(12 * 60 * (selected.getHours() < 12 ? 60 : -60)); + } else { + $scope.meridian = $scope.meridian === meridians[0] ? meridians[1] : meridians[0]; + } + } + }; + + $scope.blur = function() { + ngModelCtrl.$setTouched(); + }; + + $scope.$on('$destroy', function() { + while (watchers.length) { + watchers.shift()(); + } + }); +}]) + +.directive('uibTimepicker', ['uibTimepickerConfig', function(uibTimepickerConfig) { + return { + require: ['uibTimepicker', '?^ngModel'], + restrict: 'A', + controller: 'UibTimepickerController', + controllerAs: 'timepicker', + scope: {}, + templateUrl: function(element, attrs) { + return attrs.templateUrl || uibTimepickerConfig.templateUrl; + }, + link: function(scope, element, attrs, ctrls) { + var timepickerCtrl = ctrls[0], ngModelCtrl = ctrls[1]; + + if (ngModelCtrl) { + timepickerCtrl.init(ngModelCtrl, element.find('input')); + } } }; }]); diff --git a/src/tooltip/docs/demo.html b/src/tooltip/docs/demo.html index 8518881e85..3f97ca724e 100644 --- a/src/tooltip/docs/demo.html +++ b/src/tooltip/docs/demo.html @@ -1,33 +1,77 @@ - - Dynamic Tooltip Text: - Dynamic Tooltip Popup Text: + + Tooltip placement + + + Tooltip {{placement.selected}} + + + + Dynamic Tooltip Text + + + + Dynamic Tooltip Popup Text + + - Pellentesque {{dynamicTooltipText}}, + Pellentesque {{dynamicTooltipText}}, sit amet venenatis urna cursus eget nunc scelerisque viverra mauris, in - aliquam. Tincidunt lobortis feugiat vivamus at - left eget - arcu dictum varius duis at consectetur lorem. Vitae elementum curabitur - right - nunc sed velit dignissim sodales ut eu sem integer vitae. Turpis egestas - bottom - pharetra convallis posuere morbi leo urna, - mouse - blah blah blah, - fading - at elementum eu, facilisis sed odio morbi quis commodo odio. In cursus - delayed turpis massa tincidunt dui ut. + aliquam. Tincidunt lobortis feugiat vivamus at + fading + eget arcu dictum varius duis at consectetur lorem. Vitae elementum curabitur + show delay + nunc sed velit dignissim sodales ut eu sem integer vitae. Turpis egestas + hide delay + pharetra convallis posuere morbi leo urna, + Custom template + at elementum eu, facilisis sed odio morbi quis commodo odio. - I can even contain HTML. Check me out! + I can even contain HTML as a + scope variable or + inline string + - Or use custom triggers, like focus: - + + I can have a custom class. Check me out! - + + + + Or use custom triggers, like focus: + + + + + Disable tooltips conditionally: + + + + + Open tooltips conditionally. + + Toggle tooltip + + diff --git a/src/tooltip/docs/demo.js b/src/tooltip/docs/demo.js index f3a2c73d51..d4a2cc02a8 100644 --- a/src/tooltip/docs/demo.js +++ b/src/tooltip/docs/demo.js @@ -1,5 +1,22 @@ -var TooltipDemoCtrl = function ($scope) { - $scope.dynamicTooltip = "Hello, World!"; - $scope.dynamicTooltipText = "dynamic"; - $scope.htmlTooltip = "I've been made bold!"; -}; +angular.module('ui.bootstrap.demo').controller('TooltipDemoCtrl', function ($scope, $sce) { + $scope.dynamicTooltip = 'Hello, World!'; + $scope.dynamicTooltipText = 'dynamic'; + $scope.htmlTooltip = $sce.trustAsHtml('I\'ve been made bold!'); + $scope.placement = { + options: [ + 'top', + 'top-left', + 'top-right', + 'bottom', + 'bottom-left', + 'bottom-right', + 'left', + 'left-top', + 'left-bottom', + 'right', + 'right-top', + 'right-bottom' + ], + selected: 'top' + }; +}); diff --git a/src/tooltip/docs/readme.md b/src/tooltip/docs/readme.md index 9d812d8e3f..1bc4b04d44 100644 --- a/src/tooltip/docs/readme.md +++ b/src/tooltip/docs/readme.md @@ -1,54 +1,122 @@ A lightweight, extensible directive for fancy tooltip creation. The tooltip directive supports multiple placements, optional transition animation, and more. -There are two versions of the tooltip: `tooltip` and `tooltip-html-unsafe`. The -former takes text only and will escape any HTML provided. The latter takes -whatever HTML is provided and displays it in a tooltip; it called "unsafe" -because the HTML is not sanitized. *The user is responsible for ensuring the -content is safe to put into the DOM!* +__Note to mobile developers__: Please note that while tooltips may work correctly on mobile devices (including tablets), + we have made the decision to not officially support such a use-case because it does not make sense from a UX perspective. -The tooltip directives provide several optional attributes to control how they -will display: +There are three versions of the tooltip: `uib-tooltip`, `uib-tooltip-template`, and +`uib-tooltip-html`: -- `tooltip-placement`: Where to place it? Defaults to "top", but also accepts - "bottom", "left", "right", or "mouse". -- `tooltip-animation`: Should it fade in and out? Defaults to "true". -- `tooltip-popup-delay`: For how long should the user have to have the mouse - over the element before the tooltip shows (in milliseconds)? Defaults to 0. -- `tooltip-trigger`: What should trigger a show of the tooltip? -- `tooltip-append-to-body`: Should the tooltip be appended to `$body` instead of - the parent element? +* `uib-tooltip` - + Takes text only and will escape any HTML provided. +* `uib-tooltip-html` + $ - + Takes an expression that evaluates to an HTML string. Note that this HTML is not compiled. If compilation is required, please use the `uib-tooltip-template` attribute option instead. *The user is responsible for ensuring the content is safe to put into the DOM!* +* `uib-tooltip-template` + $ - + Takes text that specifies the location of a template to use for the tooltip. Note that this needs to be wrapped in a tag. -The tooltip directives require the `$position` service. +### uib-tooltip-* settings -**Triggers** +All these settings are available for the three types of tooltips. -The following show triggers are supported out of the box, along with their -provided hide triggers: +* `tooltip-animation` + $ + C + _(Default: `true`, Config: `animation`)_ - + Should it fade in and out? + +* `tooltip-append-to-body` + $ + C + _(Default: `false`, Config: `appendToBody`)_ - + Should the tooltip be appended to '$body' instead of the parent element? + +* `tooltip-class` - + Custom class to be applied to the tooltip. + +* `tooltip-enable` + $ + _(Default: `true`)_ - + Is it enabled? It will enable or disable the configured tooltip-trigger. + +* `tooltip-is-open` + + _(Default: `false`)_ - + Whether to show the tooltip. + +* `tooltip-placement` + C + _(Default: `top`, Config: `placement`)_ - + Passing in 'auto' separated by a space before the placement will enable auto positioning, e.g: "auto bottom-left". The tooltip will attempt to position where it fits in the closest scrollable ancestor. Accepts: + + * `top` - tooltip on top, horizontally centered on host element. + * `top-left` - tooltip on top, left edge aligned with host element left edge. + * `top-right` - tooltip on top, right edge aligned with host element right edge. + * `bottom` - tooltip on bottom, horizontally centered on host element. + * `bottom-left` - tooltip on bottom, left edge aligned with host element left edge. + * `bottom-right` - tooltip on bottom, right edge aligned with host element right edge. + * `left` - tooltip on left, vertically centered on host element. + * `left-top` - tooltip on left, top edge aligned with host element top edge. + * `left-bottom` - tooltip on left, bottom edge aligned with host element bottom edge. + * `right` - tooltip on right, vertically centered on host element. + * `right-top` - tooltip on right, top edge aligned with host element top edge. + * `right-bottom` - tooltip on right, bottom edge aligned with host element bottom edge. + +* `tooltip-popup-close-delay` + C + _(Default: `0`, Config: `popupCloseDelay`)_ - + For how long should the tooltip remain open after the close trigger event? + +* `tooltip-popup-delay` + C + _(Default: `0`, Config: `popupDelay`)_ - + Popup delay in milliseconds until it opens. + +* `tooltip-trigger` + $ + _(Default: `'mouseenter'`)_ - + What should trigger a show of the tooltip? Supports a space separated list of event names, or objects (see below). + +**Note:** To configure the tooltips, you need to do it on `$uibTooltipProvider` (also see below). + +### Triggers + +The following show triggers are supported out of the box, along with their provided hide triggers: - `mouseenter`: `mouseleave` - `click`: `click` +- `outsideClick`: `outsideClick` - `focus`: `blur` +- `none` + +The `outsideClick` trigger will cause the tooltip to toggle on click, and hide when anything else is clicked. For any non-supported value, the trigger will be used to both show and hide the -tooltip. +tooltip. Using the 'none' trigger will disable the internal trigger(s), one can +then use the `tooltip-is-open` attribute exclusively to show and hide the tooltip. -**$tooltipProvider** +### $uibTooltipProvider -Through the `$tooltipProvider`, you can change the way tooltips and popovers +Through the `$uibTooltipProvider`, you can change the way tooltips and popovers behave by default; the attributes above always take precedence. The following methods are available: -- `setTriggers( obj )`: Extends the default trigger mappings mentioned above - with mappings of your own. E.g. `{ 'openTrigger': 'closeTrigger' }`. -- `options( obj )`: Provide a set of defaults for certain tooltip and popover - attributes. Currently supports 'placement', 'animation', 'popupDelay', and - `appendToBody`. Here are the defaults: +* `setTriggers(obj)` + _(Example: `{ 'openTrigger': 'closeTrigger' }`)_ - + Extends the default trigger mappings mentioned above with mappings of your own. + +* `options(obj)` - + Provide a set of defaults for certain tooltip and popover attributes. Currently supports the ones with the C badge. + +### Known issues + +For Safari 7+ support, if you want to use the **focus** `tooltip-trigger`, you need to use an anchor tag with a tab index. For example: - - placement: 'top', - animation: true, - popupDelay: 0, - appendToBody: false - +``` + + Click Me + +``` +For Safari (potentially all versions up to 9), there is an issue with the hover CSS selector when using multiple elements grouped close to each other that are using the tooltip - it is possible for multiple elements to gain the hover state when mousing between the elements quickly and exiting the container at the right time. See [issue #5445](https://github.com/angular-ui/bootstrap/issues/5445) for more details. diff --git a/src/tooltip/index-nocss.js b/src/tooltip/index-nocss.js new file mode 100644 index 0000000000..627ad2e77f --- /dev/null +++ b/src/tooltip/index-nocss.js @@ -0,0 +1,12 @@ +require('../position/index-nocss.js'); +require('../stackedMap'); +require('../../template/tooltip/tooltip-popup.html.js'); +require('../../template/tooltip/tooltip-html-popup.html.js'); +require('../../template/tooltip/tooltip-template-popup.html.js'); +require('./tooltip'); + +var MODULE_NAME = 'ui.bootstrap.module.tooltip'; + +angular.module(MODULE_NAME, ['ui.bootstrap.tooltip', 'uib/template/tooltip/tooltip-popup.html', 'uib/template/tooltip/tooltip-html-popup.html', 'uib/template/tooltip/tooltip-template-popup.html']); + +module.exports = MODULE_NAME; diff --git a/src/tooltip/index.js b/src/tooltip/index.js new file mode 100644 index 0000000000..21d563381f --- /dev/null +++ b/src/tooltip/index.js @@ -0,0 +1,3 @@ +require('../position/position.css'); +require('./tooltip.css'); +module.exports = require('./index-nocss.js'); diff --git a/src/tooltip/test/tooltip-template.spec.js b/src/tooltip/test/tooltip-template.spec.js new file mode 100644 index 0000000000..e474c6dbc3 --- /dev/null +++ b/src/tooltip/test/tooltip-template.spec.js @@ -0,0 +1,89 @@ +describe('tooltip template', function() { + var elm, + elmBody, + scope, + elmScope, + tooltipScope, + $document; + + // load the popover code + beforeEach(module('ui.bootstrap.tooltip')); + + // load the template + beforeEach(module('uib/template/tooltip/tooltip-template-popup.html')); + + beforeEach(inject(function($templateCache) { + $templateCache.put('myUrl', [200, '{{ myTemplateText }}', {}]); + })); + + beforeEach(inject(function($rootScope, $compile, _$document_) { + $document = _$document_; + elmBody = angular.element( + 'Selector Text' + ); + + scope = $rootScope; + $compile(elmBody)(scope); + scope.templateUrl = 'myUrl'; + + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + })); + + afterEach(function() { + $document.off('keypress'); + }); + + function trigger(element, evt) { + element.trigger(evt); + element.scope().$$childTail.$digest(); + } + + it('should open on mouseenter', inject(function() { + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(true); + + expect(elmBody.children().length).toBe(2); + })); + + it('should not open on mouseenter if templateUrl is empty', inject(function() { + scope.templateUrl = null; + scope.$digest(); + + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(false); + + expect(elmBody.children().length).toBe(1); + })); + + it('should show updated text', inject(function() { + scope.myTemplateText = 'some text'; + + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(true); + scope.$digest(); + + expect(elmBody.children().eq(1).text().trim()).toBe('some text'); + + scope.myTemplateText = 'new text'; + scope.$digest(); + + expect(elmBody.children().eq(1).text().trim()).toBe('new text'); + })); + + it('should hide tooltip when template becomes empty', inject(function($timeout) { + trigger(elm, 'mouseenter'); + $timeout.flush(0); + expect(tooltipScope.isOpen).toBe(true); + + scope.templateUrl = ''; + scope.$digest(); + + expect(tooltipScope.isOpen).toBe(false); + + $timeout.flush(); + expect(elmBody.children().length).toBe(1); + })); +}); diff --git a/src/tooltip/test/tooltip.spec.js b/src/tooltip/test/tooltip.spec.js index e392124adc..3058429dae 100644 --- a/src/tooltip/test/tooltip.spec.js +++ b/src/tooltip/test/tooltip.spec.js @@ -1,121 +1,198 @@ describe('tooltip', function() { - var elm, + var elm, elmBody, - scope, - elmScope; + scope, + elmScope, + tooltipScope, + $document; // load the tooltip code beforeEach(module('ui.bootstrap.tooltip')); // load the template - beforeEach(module('template/tooltip/tooltip-popup.html')); + beforeEach(module('uib/template/tooltip/tooltip-popup.html')); - beforeEach(inject(function($rootScope, $compile) { - elmBody = angular.element( - 'Selector Text' + beforeEach(inject(function($rootScope, $compile, _$document_) { + elmBody = angular.element( + 'Selector Text' ); + $document = _$document_; scope = $rootScope; $compile(elmBody)(scope); scope.$digest(); elm = elmBody.find('span'); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; })); + afterEach(function() { + $document.off('keyup'); + }); + + function trigger(element, evt) { + element.trigger(evt); + element.scope().$$childTail.$digest(); + } + it('should not be open initially', inject(function() { - expect( elmScope.tt_isOpen ).toBe( false ); - + expect(tooltipScope.isOpen).toBe(false); + // We can only test *that* the tooltip-popup element wasn't created as the // implementation is templated and replaced. - expect( elmBody.children().length ).toBe( 1 ); + expect(elmBody.children().length).toBe(1); })); it('should open on mouseenter', inject(function() { - elm.trigger( 'mouseenter' ); - expect( elmScope.tt_isOpen ).toBe( true ); + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(true); // We can only test *that* the tooltip-popup element was created as the // implementation is templated and replaced. - expect( elmBody.children().length ).toBe( 2 ); + expect(elmBody.children().length).toBe(2); })); it('should close on mouseleave', inject(function() { - elm.trigger( 'mouseenter' ); - elm.trigger( 'mouseleave' ); - expect( elmScope.tt_isOpen ).toBe( false ); + trigger(elm, 'mouseenter'); + trigger(elm, 'mouseleave'); + expect(tooltipScope.isOpen).toBe(false); + })); + + it('should not animate on animation set to false', inject(function() { + expect(tooltipScope.animation).toBe(false); })); it('should have default placement of "top"', inject(function() { - elm.trigger( 'mouseenter' ); - expect( elmScope.tt_placement ).toBe( "top" ); + trigger(elm, 'mouseenter'); + expect(tooltipScope.placement).toBe('top'); + })); + + it('should allow specification of placement', inject(function($compile) { + elm = $compile(angular.element( + 'Selector Text' + ))(scope); + scope.$apply(); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + trigger(elm, 'mouseenter'); + expect(tooltipScope.placement).toBe('bottom'); })); - it('should allow specification of placement', inject( function( $compile ) { - elm = $compile( angular.element( - 'Selector Text' - ) )( scope ); + it('should update placement dynamically', inject(function($compile, $timeout) { + scope.place = 'bottom'; + elm = $compile(angular.element( + 'Selector Text' + ))(scope); scope.$apply(); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; - elm.trigger( 'mouseenter' ); - expect( elmScope.tt_placement ).toBe( "bottom" ); + trigger(elm, 'mouseenter'); + expect(tooltipScope.placement).toBe('bottom'); + + scope.place = 'right'; + scope.$digest(); + $timeout.flush(); + expect(tooltipScope.placement).toBe('right'); })); - it('should work inside an ngRepeat', inject( function( $compile ) { + it('should work inside an ngRepeat', inject(function($compile) { + elm = $compile(angular.element( + ''+ + ''+ + '{{item.name}}'+ + ''+ + '' + ))(scope); + + scope.items = [ + { name: 'One', tooltip: 'First Tooltip' } + ]; + + scope.$digest(); + + var tt = angular.element(elm.find('li > span')[0]); + trigger(tt, 'mouseenter'); + + expect(tt.text()).toBe(scope.items[0].name); + + tooltipScope = tt.scope().$$childTail; + expect(tooltipScope.content).toBe(scope.items[0].tooltip); - elm = $compile( angular.element( + trigger(tt, 'mouseleave'); + expect(tooltipScope.isOpen).toBeFalsy(); + })); + + it('should show correct text when in an ngRepeat', inject(function($compile, $timeout) { + elm = $compile(angular.element( ''+ ''+ - '{{item.name}}'+ + '{{item.name}}'+ ''+ '' - ) )( scope ); + ))(scope); scope.items = [ - { name: "One", tooltip: "First Tooltip" } + { name: 'One', tooltip: 'First Tooltip' }, + { name: 'Second', tooltip: 'Second Tooltip' } ]; - + scope.$digest(); - - var tt = angular.element( elm.find("li > span")[0] ); - - tt.trigger( 'mouseenter' ); - expect( tt.text() ).toBe( scope.items[0].name ); - expect( tt.scope().tt_content ).toBe( scope.items[0].tooltip ); + var tt_1 = angular.element(elm.find('li > span')[0]); + var tt_2 = angular.element(elm.find('li > span')[1]); + + trigger(tt_1, 'mouseenter'); + trigger(tt_1, 'mouseleave'); - tt.trigger( 'mouseleave' ); + $timeout.flush(); + + trigger(tt_2, 'mouseenter'); + + expect(tt_1.text()).toBe(scope.items[0].name); + expect(tt_2.text()).toBe(scope.items[1].name); + + tooltipScope = tt_2.scope().$$childTail; + expect(tooltipScope.content).toBe(scope.items[1].tooltip); + expect(elm.find('.tooltip-inner').text()).toBe(scope.items[1].tooltip); + + trigger(tt_2, 'mouseleave'); })); - it('should only have an isolate scope on the popup', inject( function ( $compile ) { + it('should only have an isolate scope on the popup', inject(function($compile) { var ttScope; - scope.tooltipMsg = "Tooltip Text"; - scope.alt = "Alt Message"; + scope.tooltipMsg = 'Tooltip Text'; + scope.alt = 'Alt Message'; - elmBody = $compile( angular.element( - 'Selector Text' - ) )( scope ); + elmBody = $compile(angular.element( + 'Selector Text' + ))(scope); - $compile( elmBody )( scope ); + $compile(elmBody)(scope); scope.$digest(); - elm = elmBody.find( 'span' ); + elm = elmBody.find('span'); elmScope = elm.scope(); - - elm.trigger( 'mouseenter' ); - expect( elm.attr( 'alt' ) ).toBe( scope.alt ); - ttScope = angular.element( elmBody.children()[1] ).scope(); - expect( ttScope.placement ).toBe( 'top' ); - expect( ttScope.content ).toBe( scope.tooltipMsg ); + trigger(elm, 'mouseenter'); + expect(elm.attr('alt')).toBe(scope.alt); - elm.trigger( 'mouseleave' ); - })); + ttScope = angular.element(elmBody.children()[1]).isolateScope(); + expect(ttScope.content).toBe(scope.tooltipMsg); + + trigger(elm, 'mouseleave'); - it('should not show tooltips if there is nothing to show - issue #129', inject(function ($compile) { + //Isolate scope contents should be the same after hiding and showing again (issue 1191) + trigger(elm, 'mouseenter'); + ttScope = angular.element(elmBody.children()[1]).isolateScope(); + expect(ttScope.content).toBe(scope.tooltipMsg); + })); + + it('should not show tooltips if there is nothing to show - issue #129', inject(function($compile) { elmBody = $compile(angular.element( - 'Selector Text' + 'Selector Text' ))(scope); scope.$digest(); elmBody.find('span').trigger('mouseenter'); @@ -123,100 +200,347 @@ describe('tooltip', function() { expect(elmBody.children().length).toBe(1); })); - it( 'should close the tooltip when its trigger element is destroyed', inject( function() { - elm.trigger( 'mouseenter' ); - expect( elmScope.tt_isOpen ).toBe( true ); + it('should close the tooltip when its trigger element is destroyed', inject(function() { + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(true); elm.remove(); elmScope.$destroy(); - expect( elmBody.children().length ).toBe( 0 ); + expect(elmBody.children().length).toBe(0); })); - describe('with specified popup delay', function () { + it('issue 1191 - scope on the popup should always be child of correct element scope', function() { + var ttScope; + trigger(elm, 'mouseenter'); - beforeEach(inject(function ($compile) { - scope.delay='1000'; + ttScope = angular.element(elmBody.children()[1]).scope(); + expect(ttScope.$parent).toBe(tooltipScope); + + trigger(elm, 'mouseleave'); + + // After leaving and coming back, the scope's parent should be the same + trigger(elm, 'mouseenter'); + + ttScope = angular.element(elmBody.children()[1]).scope(); + expect(ttScope.$parent).toBe(tooltipScope); + + trigger(elm, 'mouseleave'); + }); + + describe('with specified enable expression', function() { + beforeEach(inject(function($compile) { + scope.enable = false; + elmBody = $compile(angular.element( + 'Selector Text' + ))(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + })); + + it('should not open ', inject(function() { + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBeFalsy(); + expect(elmBody.children().length).toBe(1); + })); + + it('should open', inject(function() { + scope.enable = true; + scope.$digest(); + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBeTruthy(); + expect(elmBody.children().length).toBe(2); + })); + }); + + describe('with specified popup delay', function() { + var $timeout; + beforeEach(inject(function($compile, _$timeout_) { + $timeout = _$timeout_; + scope.delay = '1000'; elm = $compile(angular.element( - 'Selector Text' + 'Selector Text' ))(scope); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; scope.$digest(); })); - it('should open after timeout', inject(function ($timeout) { + it('should open after timeout', function() { + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(false); - elm.trigger('mouseenter'); - expect(elmScope.tt_isOpen).toBe(false); + $timeout.flush(); + expect(tooltipScope.isOpen).toBe(true); + }); + + it('should not open if mouseleave before timeout', function() { + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(false); + trigger(elm, 'mouseleave'); $timeout.flush(); - expect(elmScope.tt_isOpen).toBe(true); + expect(tooltipScope.isOpen).toBe(false); + }); + + it('should use default popup delay if specified delay is not a number', function() { + scope.delay = 'text1000'; + scope.$digest(); + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(true); + }); + + it('should not open if disabled is present', function() { + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(false); + elmScope.disabled = true; + elmScope.$digest(); + $timeout.flush(500); + + expect(tooltipScope.isOpen).toBe(false); + }); + + it('should open when not disabled after being disabled - issue #4204', function() { + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(false); + + $timeout.flush(500); + elmScope.disabled = true; + elmScope.$digest(); + + $timeout.flush(500); + expect(tooltipScope.isOpen).toBe(false); + + elmScope.disabled = false; + elmScope.$digest(); + + trigger(elm, 'mouseenter'); + $timeout.flush(); + + expect(tooltipScope.isOpen).toBe(true); + }); + + it('should close the tooltips in order', inject(function($compile) { + var elm2 = $compile('Selector Text')(scope); + scope.$digest(); + elm2 = elm2.find('span'); + var tooltipScope2 = elm2.scope().$$childTail; + tooltipScope2.isOpen = false; + scope.$digest(); + + trigger(elm, 'mouseenter'); + tooltipScope2.$digest(); + $timeout.flush(); + expect(tooltipScope.isOpen).toBe(true); + expect(tooltipScope2.isOpen).toBe(false); + + trigger(elm2, 'mouseenter'); + tooltipScope2.$digest(); + $timeout.flush(); + expect(tooltipScope.isOpen).toBe(true); + expect(tooltipScope2.isOpen).toBe(true); + + var evt = $.Event('keyup'); + evt.which = 27; + + $document.trigger(evt); + tooltipScope.$digest(); + tooltipScope2.$digest(); + $timeout.flush(); + + expect(tooltipScope.isOpen).toBe(true); + expect(tooltipScope2.isOpen).toBe(false); + + var evt2 = $.Event('keyup'); + evt2.which = 27; + + $document.trigger(evt2); + tooltipScope.$digest(); + tooltipScope2.$digest(); + $timeout.flush(500); + + expect(tooltipScope.isOpen).toBe(false); + expect(tooltipScope2.isOpen).toBe(false); })); + }); - it('should not open if mouseleave before timeout', inject(function ($timeout) { - elm.trigger('mouseenter'); - expect(elmScope.tt_isOpen).toBe(false); + describe('with specified popup close delay', function() { + var $timeout; + beforeEach(inject(function($compile, _$timeout_) { + $timeout = _$timeout_; + scope.delay = '1000'; + elm = $compile(angular.element( + 'Selector Text' + ))(scope); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + scope.$digest(); + })); - elm.trigger('mouseleave'); + it('should close after timeout', function() { + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(true); + trigger(elm, 'mouseleave'); $timeout.flush(); - expect(elmScope.tt_isOpen).toBe(false); + expect(tooltipScope.isOpen).toBe(false); + }); + + it('should use default popup close delay if specified delay is not a number and close immediately', function() { + scope.delay = 'text1000'; + scope.$digest(); + trigger(elm, 'mouseenter'); + expect(tooltipScope.popupCloseDelay).toBe(0); + expect(tooltipScope.isOpen).toBe(true); + trigger(elm, 'mouseleave'); + $timeout.flush(); + expect(tooltipScope.isOpen).toBe(false); + }); + + it('should open when not disabled after being disabled and close after delay - issue #4204', function() { + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(true); + + elmScope.disabled = true; + elmScope.$digest(); + + $timeout.flush(500); + expect(tooltipScope.isOpen).toBe(false); + + elmScope.disabled = false; + elmScope.$digest(); + + trigger(elm, 'mouseenter'); + + expect(tooltipScope.isOpen).toBe(true); + trigger(elm, 'mouseleave'); + $timeout.flush(); + expect(tooltipScope.isOpen).toBe(false); + }); + }); + + describe('with specified popup and popup close delay', function() { + var $timeout; + beforeEach(inject(function($compile, _$timeout_) { + $timeout = _$timeout_; + scope.delay = '1000'; + elm = $compile(angular.element( + 'Selector Text' + ))(scope); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + scope.$digest(); })); - it('should use default popup delay if specified delay is not a number', function(){ - scope.delay='text1000'; + it('should not open if mouseleave before timeout', function() { + trigger(elm, 'mouseenter'); + $timeout.flush(500); + trigger(elm, 'mouseleave'); + $timeout.flush(); + + expect(tooltipScope.isOpen).toBe(false); + }); + }); + + describe('with an is-open attribute', function() { + beforeEach(inject(function ($compile) { + scope.isOpen = false; + elm = $compile(angular.element( + 'Selector Text' + ))(scope); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; scope.$digest(); - elm.trigger('mouseenter'); - expect(elmScope.tt_isOpen).toBe(true); + })); + + it('should show and hide with the controller value', function() { + expect(tooltipScope.isOpen).toBe(false); + elmScope.isOpen = true; + elmScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + elmScope.isOpen = false; + elmScope.$digest(); + expect(tooltipScope.isOpen).toBe(false); }); + it('should update the controller value', function() { + trigger(elm, 'mouseenter'); + expect(elmScope.isOpen).toBe(true); + trigger(elm, 'mouseleave'); + expect(elmScope.isOpen).toBe(false); + }); }); - describe( 'with a trigger attribute', function() { + describe('with an is-open attribute expression', function() { + beforeEach(inject(function($compile) { + scope.isOpen = false; + elm = $compile(angular.element( + 'Selector Text' + ))(scope); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + scope.$digest(); + })); + + it('should show and hide with the expression', function() { + expect(tooltipScope.isOpen).toBe(false); + elmScope.isOpen = true; + elmScope.$digest(); + expect(tooltipScope.isOpen).toBe(true); + elmScope.isOpen = false; + elmScope.$digest(); + expect(tooltipScope.isOpen).toBe(false); + }); + }); + + describe('with a trigger attribute', function() { var scope, elmBody, elm, elmScope; - beforeEach( inject( function( $rootScope ) { + beforeEach(inject(function($rootScope) { scope = $rootScope; })); - it( 'should use it to show but set the hide trigger based on the map for mapped triggers', inject( function( $compile ) { + it('should use it to show but set the hide trigger based on the map for mapped triggers', inject(function($compile) { elmBody = angular.element( - '' + '' ); $compile(elmBody)(scope); scope.$apply(); elm = elmBody.find('input'); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; - expect( elmScope.tt_isOpen ).toBeFalsy(); - elm.trigger('focus'); - expect( elmScope.tt_isOpen ).toBeTruthy(); - elm.trigger('blur'); - expect( elmScope.tt_isOpen ).toBeFalsy(); + expect(tooltipScope.isOpen).toBeFalsy(); + trigger(elm, 'focus'); + expect(tooltipScope.isOpen).toBeTruthy(); + trigger(elm, 'blur'); + expect(tooltipScope.isOpen).toBeFalsy(); })); - it( 'should use it as both the show and hide triggers for unmapped triggers', inject( function( $compile ) { + it('should use it as both the show and hide triggers for unmapped triggers', inject(function($compile) { elmBody = angular.element( - '' + '' ); $compile(elmBody)(scope); scope.$apply(); elm = elmBody.find('input'); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; - expect( elmScope.tt_isOpen ).toBeFalsy(); - elm.trigger('fakeTriggerAttr'); - expect( elmScope.tt_isOpen ).toBeTruthy(); - elm.trigger('fakeTriggerAttr'); - expect( elmScope.tt_isOpen ).toBeFalsy(); + expect(tooltipScope.isOpen).toBeFalsy(); + trigger(elm, 'fakeTriggerAttr'); + expect(tooltipScope.isOpen).toBeTruthy(); + trigger(elm, 'fakeTriggerAttr'); + expect(tooltipScope.isOpen).toBeFalsy(); })); - it('should not share triggers among different element instances - issue 692', inject( function ($compile) { - + it('should only set up triggers once', inject(function($compile) { scope.test = true; elmBody = angular.element( '' + - '' + - '' + + '' + + '' + '' ); @@ -226,51 +550,136 @@ describe('tooltip', function() { var elm2 = elmBody.find('input').eq(1); var elmScope1 = elm1.scope(); var elmScope2 = elm2.scope(); + var tooltipScope2 = elmScope2.$$childTail; scope.$apply('test = false'); - elm2.trigger('mouseenter'); - expect( elmScope2.tt_isOpen ).toBeFalsy(); - + // click trigger isn't set elm2.click(); - expect( elmScope2.tt_isOpen ).toBeTruthy(); + expect(tooltipScope2.isOpen).toBeFalsy(); + + // mouseenter trigger is still set + trigger(elm2, 'mouseenter'); + expect(tooltipScope2.isOpen).toBeTruthy(); + })); + + it('should accept multiple triggers based on the map for mapped triggers', inject(function($compile) { + elmBody = angular.element( + '' + ); + $compile(elmBody)(scope); + scope.$apply(); + elm = elmBody.find('input'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + expect(tooltipScope.isOpen).toBeFalsy(); + trigger(elm, 'focus'); + expect(tooltipScope.isOpen).toBeTruthy(); + trigger(elm, 'blur'); + expect(tooltipScope.isOpen).toBeFalsy(); + trigger(elm, 'fakeTriggerAttr'); + expect(tooltipScope.isOpen).toBeTruthy(); + trigger(elm, 'fakeTriggerAttr'); + expect(tooltipScope.isOpen).toBeFalsy(); + })); + + it('should not show when trigger is set to "none"', inject(function($compile) { + elmBody = angular.element( + '' + ); + $compile(elmBody)(scope); + scope.$apply(); + elm = elmBody.find('input'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + expect(tooltipScope.isOpen).toBeFalsy(); + elm.trigger('mouseenter'); + expect(tooltipScope.isOpen).toBeFalsy(); + })); + + it('should toggle on click and hide when anything else is clicked when trigger is set to "outsideClick"', inject(function($compile, $document) { + elm = $compile(angular.element( + 'Selector Text' + ))(scope); + scope.$apply(); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + // start off + expect(tooltipScope.isOpen).toBeFalsy(); + + // toggle + trigger(elm, 'click'); + expect(tooltipScope.isOpen).toBeTruthy(); + trigger(elm, 'click'); + expect(tooltipScope.isOpen).toBeFalsy(); + + // click on, outsideClick off + trigger(elm, 'click'); + expect(tooltipScope.isOpen).toBeTruthy(); + angular.element($document[0].body).trigger('click'); + tooltipScope.$digest(); + expect(tooltipScope.isOpen).toBeFalsy(); + })); + + it('should support objects', inject(function($compile) { + elmBody = angular.element( + '' + ); + $compile(elmBody)(scope); + scope.$apply(); + elm = elmBody.find('input'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + expect(tooltipScope.isOpen).toBeFalsy(); + trigger(elm, 'show'); + expect(tooltipScope.isOpen).toBeTruthy(); + trigger(elm, 'hide'); + expect(tooltipScope.isOpen).toBeFalsy(); })); }); - describe( 'with an append-to-body attribute', function() { - var scope, elmBody, elm, elmScope; + describe('with an append-to-body attribute', function() { + var scope, elmBody, elm, elmScope, $body; - beforeEach( inject( function( $rootScope ) { + beforeEach(inject(function($rootScope) { scope = $rootScope; })); - it( 'should append to the body', inject( function( $compile, $document ) { - $body = $document.find( 'body' ); - elmBody = angular.element( - 'Selector Text' + afterEach(function() { + $body.find('.tooltip').remove(); + }); + + it('should append to the body', inject(function($compile, $document) { + $body = $document.find('body'); + elmBody = angular.element( + 'Selector Text' ); $compile(elmBody)(scope); scope.$digest(); elm = elmBody.find('span'); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; var bodyLength = $body.children().length; - elm.trigger( 'mouseenter' ); - - expect( elmScope.tt_isOpen ).toBe( true ); - expect( elmBody.children().length ).toBe( 1 ); - expect( $body.children().length ).toEqual( bodyLength + 1 ); + trigger(elm, 'mouseenter'); + + expect(tooltipScope.isOpen).toBe(true); + expect(elmBody.children().length).toBe(1); + expect($body.children().length).toEqual(bodyLength + 1); })); }); - describe('cleanup', function () { + describe('cleanup', function() { var elmBody, elm, elmScope, tooltipScope; function inCache() { var match = false; - angular.forEach(angular.element.cache, function (item) { + angular.forEach(angular.element.cache, function(item) { if (item.data && item.data.$scope === tooltipScope) { match = true; } @@ -279,123 +688,238 @@ describe('tooltip', function() { return match; } - beforeEach(inject(function ( $compile, $rootScope ) { - elmBody = angular.element(''); + beforeEach(inject(function($compile, $rootScope) { + elmBody = angular.element(''); $compile(elmBody)($rootScope); $rootScope.$apply(); elm = elmBody.find('input'); elmScope = elm.scope(); - tooltipScope = elmScope.$$childTail; + trigger(elm, 'fooTrigger'); + tooltipScope = elmScope.$$childTail.$$childTail; })); - it( 'should not contain a cached reference', function() { - expect( inCache() ).toBeTruthy(); + it('should not contain a cached reference when not visible', inject(function($timeout) { + expect(inCache()).toBeTruthy(); elmScope.$destroy(); - expect( inCache() ).toBeFalsy(); - }); + expect(inCache()).toBeFalsy(); + })); + }); - it( 'should not contain a cached reference when visible', inject( function( $timeout ) { - expect( inCache() ).toBeTruthy(); - elm.trigger('fooTrigger'); - elmScope.$destroy(); + describe('observers', function() { + var elmBody, elm, elmScope, scope, tooltipScope; + + beforeEach(inject(function($compile, $rootScope) { + scope = $rootScope; + scope.content = 'tooltip content'; + scope.placement = 'top'; + elmBody = angular.element(''); + $compile(elmBody)(scope); + scope.$apply(); + + elm = elmBody.find('input'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + })); + + it('should be removed when tooltip hides', inject(function($timeout) { + expect(tooltipScope.content).toBe(undefined); + expect(tooltipScope.placement).toBe(undefined); + + trigger(elm, 'mouseenter'); + expect(tooltipScope.content).toBe('tooltip content'); + expect(tooltipScope.placement).toBe('top'); + scope.content = 'tooltip content updated'; + + scope.placement = 'bottom'; + scope.$apply(); + expect(tooltipScope.content).toBe('tooltip content updated'); + expect(tooltipScope.placement).toBe('bottom'); + + trigger(elm, 'mouseleave'); $timeout.flush(); - expect( inCache() ).toBeFalsy(); + scope.content = 'tooltip content updated after close'; + scope.placement = 'left'; + scope.$apply(); + expect(tooltipScope.content).toBe('tooltip content updated'); + expect(tooltipScope.placement).toBe('bottom'); })); }); }); describe('tooltipWithDifferentSymbols', function() { - var elm, - elmBody, - scope, - elmScope; + var elmBody; // load the tooltip code beforeEach(module('ui.bootstrap.tooltip')); // load the template - beforeEach(module('template/tooltip/tooltip-popup.html')); + beforeEach(module('uib/template/tooltip/tooltip-popup.html')); // configure interpolate provider to use [[ ]] instead of {{ }} - beforeEach(module( function($interpolateProvider) { - $interpolateProvider.startSymbol('[['); - $interpolateProvider.startSymbol(']]'); - })); + beforeEach(module(function($interpolateProvider) { + $interpolateProvider.startSymbol('[['); + $interpolateProvider.startSymbol(']]'); + })); - it( 'should show the correct tooltip text', inject( function ( $compile, $rootScope ) { + function trigger(element, evt) { + element.trigger(evt); + element.scope().$$childTail.$digest(); + } + it('should show the correct tooltip text', inject(function($compile, $rootScope) { elmBody = angular.element( - '' + '' ); $compile(elmBody)($rootScope); $rootScope.$apply(); - elmInput = elmBody.find('input'); - elmInput.trigger('focus'); - - expect( elmInput.next().find('div').next().html() ).toBe('My tooltip'); + var elmInput = elmBody.find('input'); + trigger(elmInput, 'focus'); + expect(elmInput.next().find('div').next().html()).toBe('My tooltip'); })); +}); + +describe('tooltip positioning', function() { + var elm, elmBody, elmScope, tooltipScope, scope; + var $position; + + // load the tooltip code + beforeEach(module('ui.bootstrap.tooltip', function($uibTooltipProvider) { + $uibTooltipProvider.options({ animation: false }); + })); + + // load the template + beforeEach(module('uib/template/tooltip/tooltip-popup.html')); + + beforeEach(inject(function($rootScope, $compile, $uibPosition) { + $position = $uibPosition; + spyOn($position, 'positionElements').and.callThrough(); + + scope = $rootScope; + scope.text = 'Some Text'; + + elmBody = $compile(angular.element( + 'Selector Text' + ))(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + })); + + function trigger(element, evt) { + element.trigger(evt); + element.scope().$$childTail.$digest(); + } + + it('should re-position when value changes', inject(function($timeout) { + trigger(elm, 'mouseenter'); + + scope.$digest(); + $timeout.flush(); + var startingPositionCalls = $position.positionElements.calls.count(); + + scope.text = 'New Text'; + scope.$digest(); + $timeout.flush(); + expect(elm.attr('uib-tooltip')).toBe('New Text'); + expect($position.positionElements.calls.count()).toEqual(startingPositionCalls + 1); + // Check that positionElements was called with elm + expect($position.positionElements.calls.argsFor(startingPositionCalls)[0][0]) + .toBe(elm[0]); + + scope.$digest(); + $timeout.verifyNoPendingTasks(); + expect($position.positionElements.calls.count()).toEqual(startingPositionCalls + 1); + expect($position.positionElements.calls.argsFor(startingPositionCalls)[0][0]) + .toBe(elm[0]); + scope.$digest(); + })); }); -describe( 'tooltipHtmlUnsafe', function() { - var elm, elmBody, scope; +describe('tooltipHtml', function() { + var elm, elmBody, elmScope, tooltipScope, scope; // load the tooltip code - beforeEach(module('ui.bootstrap.tooltip', function ( $tooltipProvider ) { - $tooltipProvider.options({ animation: false }); + beforeEach(module('ui.bootstrap.tooltip', function($uibTooltipProvider) { + $uibTooltipProvider.options({ animation: false }); })); // load the template - beforeEach(module('template/tooltip/tooltip-html-unsafe-popup.html')); + beforeEach(module('uib/template/tooltip/tooltip-html-popup.html')); - beforeEach(inject(function($rootScope, $compile) { + beforeEach(inject(function($rootScope, $compile, $sce) { scope = $rootScope; scope.html = 'I say: Hello!'; + scope.safeHtml = $sce.trustAsHtml(scope.html); - elmBody = $compile( angular.element( - 'Selector Text' - ))( scope ); + elmBody = $compile(angular.element( + 'Selector Text' + ))(scope); scope.$digest(); elm = elmBody.find('span'); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + })); + + function trigger(element, evt) { + element.trigger(evt); + element.scope().$$childTail.$digest(); + } + + it('should render html properly', inject(function() { + trigger(elm, 'mouseenter'); + expect(elmBody.find('.tooltip-inner').html()).toBe(scope.html); })); - it( 'should show on mouseenter and hide on mouseleave', inject( function () { - expect( elmScope.tt_isOpen ).toBe( false ); + it('should not open if html is empty', function() { + scope.safeHtml = null; + scope.$digest(); + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(false); + }); - elm.trigger( 'mouseenter' ); - expect( elmScope.tt_isOpen ).toBe( true ); - expect( elmBody.children().length ).toBe( 2 ); + it('should show on mouseenter and hide on mouseleave', inject(function($sce) { + expect(tooltipScope.isOpen).toBe(false); - expect( elmScope.tt_content ).toEqual( scope.html ); + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(true); + expect(elmBody.children().length).toBe(2); - elm.trigger( 'mouseleave' ); - expect( elmScope.tt_isOpen ).toBe( false ); - expect( elmBody.children().length ).toBe( 1 ); + expect($sce.getTrustedHtml(tooltipScope.contentExp())).toEqual(scope.html); + + trigger(elm, 'mouseleave'); + expect(tooltipScope.isOpen).toBe(false); + expect(elmBody.children().length).toBe(1); })); }); -describe( '$tooltipProvider', function() { - var elm, +describe('$uibTooltipProvider', function() { + var elm, elmBody, - scope, + scope, elmScope, - body; + tooltipScope; - describe( 'popupDelay', function() { - beforeEach(module('ui.bootstrap.tooltip', function($tooltipProvider){ - $tooltipProvider.options({popupDelay: 1000}); + function trigger(element, evt) { + element.trigger(evt); + element.scope().$$childTail.$digest(); + } + + describe('popupDelay', function() { + beforeEach(module('ui.bootstrap.tooltip', function($uibTooltipProvider) { + $uibTooltipProvider.options({popupDelay: 1000}); })); // load the template - beforeEach(module('template/tooltip/tooltip-popup.html')); + beforeEach(module('uib/template/tooltip/tooltip-popup.html')); beforeEach(inject(function($rootScope, $compile) { elmBody = angular.element( - 'Selector Text' + 'Selector Text' ); scope = $rootScope; @@ -403,33 +927,34 @@ describe( '$tooltipProvider', function() { scope.$digest(); elm = elmBody.find('span'); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; })); it('should open after timeout', inject(function($timeout) { - - elm.trigger( 'mouseenter' ); - expect( elmScope.tt_isOpen ).toBe( false ); + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBe(false); $timeout.flush(); - expect( elmScope.tt_isOpen ).toBe( true ); - + expect(tooltipScope.isOpen).toBe(true); })); - }); describe('appendToBody', function() { - // load the tooltip code - beforeEach(module('ui.bootstrap.tooltip', function ( $tooltipProvider ) { - $tooltipProvider.options({ appendToBody: true }); + var $body; + + beforeEach(module('uib/template/tooltip/tooltip-popup.html')); + beforeEach(module('ui.bootstrap.tooltip', function($uibTooltipProvider) { + $uibTooltipProvider.options({ appendToBody: true }); })); - // load the template - beforeEach(module('template/tooltip/tooltip-popup.html')); + afterEach(function() { + $body.find('.tooltip').remove(); + }); - it( 'should append to the body', inject( function( $rootScope, $compile, $document ) { - $body = $document.find( 'body' ); - elmBody = angular.element( - 'Selector Text' + it('should append to the body', inject(function($rootScope, $compile, $document) { + $body = $document.find('body'); + elmBody = angular.element( + 'Selector Text' ); scope = $rootScope; @@ -437,19 +962,20 @@ describe( '$tooltipProvider', function() { scope.$digest(); elm = elmBody.find('span'); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; var bodyLength = $body.children().length; - elm.trigger( 'mouseenter' ); - - expect( elmScope.tt_isOpen ).toBe( true ); - expect( elmBody.children().length ).toBe( 1 ); - expect( $body.children().length ).toEqual( bodyLength + 1 ); - })); + trigger(elm, 'mouseenter'); - it('should close on location change', inject( function( $rootScope, $compile) { + expect(tooltipScope.isOpen).toBe(true); + expect(elmBody.children().length).toBe(1); + expect($body.children().length).toEqual(bodyLength + 1); + })); + it('should append to the body when only attribute present', inject(function($rootScope, $compile, $document) { + $body = $document.find('body'); elmBody = angular.element( - 'Selector Text' + 'Selector Text' ); scope = $rootScope; @@ -457,46 +983,70 @@ describe( '$tooltipProvider', function() { scope.$digest(); elm = elmBody.find('span'); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; - elm.trigger( 'mouseenter' ); - expect( elmScope.tt_isOpen ).toBe( true ); + var bodyLength = $body.children().length; + trigger(elm, 'mouseenter'); - scope.$broadcast('$locationChangeSuccess'); + expect(tooltipScope.isOpen).toBe(true); + expect(elmBody.children().length).toBe(1); + expect($body.children().length).toEqual(bodyLength + 1); + })); + + it('should not append to the body when attribute value is false', inject(function($rootScope, $compile, $document) { + $body = $document.find('body'); + elmBody = angular.element( + 'Selector Text' + ); + + scope = $rootScope; + $compile(elmBody)(scope); scope.$digest(); - expect( elmScope.tt_isOpen ).toBe( false ); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + var bodyLength = $body.children().length; + trigger(elm, 'mouseenter'); + + expect(tooltipScope.isOpen).toBe(true); + expect(elmBody.children().length).toBe(2); + expect($body.children().length).toEqual(bodyLength); })); + }); - describe( 'triggers', function() { - describe( 'triggers with a mapped value', function() { - beforeEach(module('ui.bootstrap.tooltip', function($tooltipProvider){ - $tooltipProvider.options({trigger: 'focus'}); + describe('triggers', function() { + describe('with a mapped value', function() { + beforeEach(module('ui.bootstrap.tooltip', function($uibTooltipProvider) { + $uibTooltipProvider.options({trigger: 'focus'}); })); // load the template - beforeEach(module('template/tooltip/tooltip-popup.html')); + beforeEach(module('uib/template/tooltip/tooltip-popup.html')); - it( 'should use the show trigger and the mapped value for the hide trigger', inject( function ( $rootScope, $compile ) { + it('should use the show trigger and the mapped value for the hide trigger', inject(function($rootScope, $compile) { elmBody = angular.element( - '' + '' ); - + scope = $rootScope; $compile(elmBody)(scope); scope.$digest(); elm = elmBody.find('input'); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; - expect( elmScope.tt_isOpen ).toBeFalsy(); - elm.trigger('focus'); - expect( elmScope.tt_isOpen ).toBeTruthy(); - elm.trigger('blur'); - expect( elmScope.tt_isOpen ).toBeFalsy(); + expect(tooltipScope.isOpen).toBeFalsy(); + trigger(elm, 'focus'); + expect(tooltipScope.isOpen).toBeTruthy(); + trigger(elm, 'blur'); + expect(tooltipScope.isOpen).toBeFalsy(); })); - it( 'should override the show and hide triggers if there is an attribute', inject( function ( $rootScope, $compile ) { + it('should override the show and hide triggers if there is an attribute', inject(function($rootScope, $compile) { elmBody = angular.element( - '' + '' ); scope = $rootScope; @@ -504,69 +1054,106 @@ describe( '$tooltipProvider', function() { scope.$digest(); elm = elmBody.find('input'); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; - expect( elmScope.tt_isOpen ).toBeFalsy(); - elm.trigger('mouseenter'); - expect( elmScope.tt_isOpen ).toBeTruthy(); - elm.trigger('mouseleave'); - expect( elmScope.tt_isOpen ).toBeFalsy(); + expect(tooltipScope.isOpen).toBeFalsy(); + trigger(elm, 'mouseenter'); + expect(tooltipScope.isOpen).toBeTruthy(); + trigger(elm, 'mouseleave'); + expect(tooltipScope.isOpen).toBeFalsy(); })); }); - describe( 'triggers with a custom mapped value', function() { - beforeEach(module('ui.bootstrap.tooltip', function($tooltipProvider){ - $tooltipProvider.setTriggers({ 'customOpenTrigger': 'customCloseTrigger' }); - $tooltipProvider.options({trigger: 'customOpenTrigger'}); + describe('with a custom mapped value', function() { + beforeEach(module('ui.bootstrap.tooltip', function($uibTooltipProvider) { + $uibTooltipProvider.setTriggers({ customOpenTrigger: 'foo bar' }); + $uibTooltipProvider.options({trigger: 'customOpenTrigger'}); })); // load the template - beforeEach(module('template/tooltip/tooltip-popup.html')); + beforeEach(module('uib/template/tooltip/tooltip-popup.html')); - it( 'should use the show trigger and the mapped value for the hide trigger', inject( function ( $rootScope, $compile ) { + it('should use the show trigger and the mapped value for the hide trigger', inject(function($rootScope, $compile) { elmBody = angular.element( - '' + '' ); - + scope = $rootScope; $compile(elmBody)(scope); scope.$digest(); elm = elmBody.find('input'); elmScope = elm.scope(); - - expect( elmScope.tt_isOpen ).toBeFalsy(); - elm.trigger('customOpenTrigger'); - expect( elmScope.tt_isOpen ).toBeTruthy(); - elm.trigger('customCloseTrigger'); - expect( elmScope.tt_isOpen ).toBeFalsy(); + tooltipScope = elmScope.$$childTail; + + expect(tooltipScope.isOpen).toBeFalsy(); + trigger(elm, 'customOpenTrigger'); + expect(tooltipScope.isOpen).toBeTruthy(); + trigger(elm, 'foo'); + expect(tooltipScope.isOpen).toBeFalsy(); + trigger(elm, 'customOpenTrigger'); + expect(tooltipScope.isOpen).toBeTruthy(); + trigger(elm, 'bar'); + expect(tooltipScope.isOpen).toBeFalsy(); })); }); - describe( 'triggers without a mapped value', function() { - beforeEach(module('ui.bootstrap.tooltip', function($tooltipProvider){ - $tooltipProvider.options({trigger: 'fakeTrigger'}); + describe('triggers without a mapped value', function() { + beforeEach(module('ui.bootstrap.tooltip', function($uibTooltipProvider) { + $uibTooltipProvider.options({trigger: 'fakeTrigger'}); })); // load the template - beforeEach(module('template/tooltip/tooltip-popup.html')); + beforeEach(module('uib/template/tooltip/tooltip-popup.html')); - it( 'should use the show trigger to hide', inject( function ( $rootScope, $compile ) { + it('should use the show trigger to hide', inject(function($rootScope, $compile) { elmBody = angular.element( - 'Selector Text' + 'Selector Text' ); - + scope = $rootScope; $compile(elmBody)(scope); scope.$digest(); elm = elmBody.find('span'); elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; - expect( elmScope.tt_isOpen ).toBeFalsy(); - elm.trigger('fakeTrigger'); - expect( elmScope.tt_isOpen ).toBeTruthy(); - elm.trigger('fakeTrigger'); - expect( elmScope.tt_isOpen ).toBeFalsy(); + expect(tooltipScope.isOpen).toBeFalsy(); + trigger(elm, 'fakeTrigger'); + expect(tooltipScope.isOpen).toBeTruthy(); + trigger(elm, 'fakeTrigger'); + expect(tooltipScope.isOpen).toBeFalsy(); })); }); }); -}); + describe('placementClassPrefix', function() { + beforeEach(module('ui.bootstrap.tooltip', function($uibTooltipProvider) { + $uibTooltipProvider.options({placementClassPrefix: 'uib-'}); + })); + + // load the template + beforeEach(module('uib/template/tooltip/tooltip-popup.html')); + + it('should add the classes', inject(function($rootScope, $compile, $timeout) { + elmBody = angular.element( + '' + ); + + scope = $rootScope; + $compile(elmBody)(scope); + scope.$digest(); + elm = elmBody.find('span'); + elmScope = elm.scope(); + tooltipScope = elmScope.$$childTail; + + expect(elmBody.children().length).toBe(1); + + trigger(elm, 'mouseenter'); + $timeout.flush(); + + var tooltipElm = elmBody.find('.tooltip'); + expect(tooltipElm.hasClass('top')).toBe(true); + expect(tooltipElm.hasClass('uib-top-right')).toBe(true); + })); + }); +}); diff --git a/src/tooltip/test/tooltip2.spec.js b/src/tooltip/test/tooltip2.spec.js new file mode 100644 index 0000000000..81326e466b --- /dev/null +++ b/src/tooltip/test/tooltip2.spec.js @@ -0,0 +1,196 @@ +describe('tooltip directive', function() { + var $rootScope, $compile, $document, $timeout, body, fragment; + + beforeEach(module('ui.bootstrap.tooltip')); + beforeEach(module('uib/template/tooltip/tooltip-popup.html')); + beforeEach(module('uib/template/tooltip/tooltip-template-popup.html')); + beforeEach(module('uib/template/tooltip/tooltip-html-popup.html')); + beforeEach(inject(function(_$rootScope_, _$compile_, _$document_, _$timeout_) { + $rootScope = _$rootScope_; + $compile = _$compile_; + $document = _$document_; + $timeout = _$timeout_; + + body = $document.find('body'); + })); + + beforeEach(function() { + jasmine.addMatchers({ + toHaveOpenTooltips: function(util, customEqualityTesters) { + return { + compare: function(actual, noOfOpened) { + var ttipElements = actual.find('div.tooltip'); + noOfOpened = noOfOpened || 1; + + var result = { + pass: util.equals(ttipElements.length, noOfOpened, customEqualityTesters) + }; + + if (result.pass) { + result.message = 'Expected "' + angular.mock.dump(ttipElements) + '" not to have "' + ttipElements.length + '" opened tooltips.'; + } else { + result.message = 'Expected "' + angular.mock.dump(ttipElements) + '" to have "' + ttipElements.length + '" opened tooltips.'; + } + + return result; + } + }; + } + }); + }); + + afterEach(function() { + $document.off('keypress'); + fragment.remove(); + }); + + function compileTooltip(ttipMarkup) { + fragment = $compile('' + ttipMarkup + '')($rootScope); + $rootScope.$digest(); + body.append(fragment); + } + + function closeTooltip(hostEl, triggerEvt, shouldNotFlush) { + trigger(hostEl, triggerEvt || 'mouseleave'); + hostEl.scope().$$childTail.$digest(); + if (!shouldNotFlush) { + $timeout.flush(); + } + } + + function trigger(element, evt) { + element.trigger(evt); + element.scope().$$childTail.$digest(); + } + + describe('basic scenarios with default options', function() { + it('shows default tooltip on mouse enter and closes on mouse leave', function() { + compileTooltip('Trigger here'); + + trigger(fragment.find('span'), 'mouseenter'); + expect(fragment).toHaveOpenTooltips(); + + closeTooltip(fragment.find('span')); + expect(fragment).not.toHaveOpenTooltips(); + }); + + it('should not show a tooltip when its content is empty', function() { + compileTooltip(''); + trigger(fragment.find('span'), 'mouseenter'); + expect(fragment).not.toHaveOpenTooltips(); + }); + + it('should not show a tooltip when its content becomes empty', function() { + $rootScope.content = 'some text'; + compileTooltip(''); + + trigger(fragment.find('span'), 'mouseenter'); + $timeout.flush(0); + expect(fragment).toHaveOpenTooltips(); + + $rootScope.content = ''; + $rootScope.$digest(); + $timeout.flush(); + expect(fragment).not.toHaveOpenTooltips(); + }); + + it('should update tooltip when its content becomes empty', function() { + $rootScope.content = 'some text'; + compileTooltip(''); + + $rootScope.content = ''; + $rootScope.$digest(); + + trigger(fragment.find('span'), 'mouseenter'); + expect(fragment).not.toHaveOpenTooltips(); + }); + }); + + describe('option by option', function() { + var tooltipTypes = { + 'tooltip': 'uib-tooltip="tooltip text"', + 'tooltip-html': 'uib-tooltip-html="tooltipSafeHtml"', + 'tooltip-template': 'uib-tooltip-template="\'tooltipTextUrl\'"' + }; + + beforeEach(inject(function($sce, $templateCache) { + $rootScope.tooltipText = 'tooltip text'; + $rootScope.tooltipSafeHtml = $sce.trustAsHtml('tooltip text'); + $templateCache.put('tooltipTextUrl', [200, 'tooltip text', {}]); + })); + + angular.forEach(tooltipTypes, function(html, key) { + describe(key, function() { + describe('placement', function() { + it('can specify an alternative, valid placement', function() { + compileTooltip('Trigger here'); + trigger(fragment.find('span'), 'mouseenter'); + + var ttipElement = fragment.find('div.tooltip'); + expect(fragment).toHaveOpenTooltips(); + expect(ttipElement).toHaveClass('left'); + + closeTooltip(fragment.find('span')); + expect(fragment).not.toHaveOpenTooltips(); + }); + }); + + describe('class', function() { + it('can specify a custom class', function() { + compileTooltip('Trigger here'); + trigger(fragment.find('span'), 'mouseenter'); + + var ttipElement = fragment.find('div.tooltip'); + expect(fragment).toHaveOpenTooltips(); + expect(ttipElement).toHaveClass('custom'); + + closeTooltip(fragment.find('span')); + expect(fragment).not.toHaveOpenTooltips(); + }); + }); + }); + }); + }); + + it('should show even after close trigger is called multiple times - issue #1847', function() { + compileTooltip('Trigger here'); + + trigger(fragment.find('span'), 'mouseenter'); + expect(fragment).toHaveOpenTooltips(); + + closeTooltip(fragment.find('span'), null, true); + // Close trigger is called again before timer completes + // The close trigger can be called any number of times (even after close has already been called) + // since users can trigger the hide triggers manually. + closeTooltip(fragment.find('span'), null, true); + expect(fragment).toHaveOpenTooltips(); + + trigger(fragment.find('span'), 'mouseenter'); + expect(fragment).toHaveOpenTooltips(); + + $timeout.flush(); + expect(fragment).toHaveOpenTooltips(); + }); + + it('should hide even after show trigger is called multiple times', function() { + compileTooltip('Trigger here'); + + trigger(fragment.find('span'), 'mouseenter'); + trigger(fragment.find('span'), 'mouseenter'); + + closeTooltip(fragment.find('span')); + expect(fragment).not.toHaveOpenTooltips(); + }); + + it('should not show tooltips element is disabled (button) - issue #3167', function() { + compileTooltip('Cancel'); + + trigger(fragment.find('button'), 'mouseenter'); + expect(fragment).toHaveOpenTooltips(); + + trigger(fragment.find('button'), 'click'); + $timeout.flush(); + // One needs to flush deferred functions before checking there is no tooltip. + expect(fragment).not.toHaveOpenTooltips(); + }); +}); diff --git a/src/tooltip/tooltip.css b/src/tooltip/tooltip.css new file mode 100644 index 0000000000..04e5c9f75a --- /dev/null +++ b/src/tooltip/tooltip.css @@ -0,0 +1,60 @@ +[uib-tooltip-popup].tooltip.top-left > .tooltip-arrow, +[uib-tooltip-popup].tooltip.top-right > .tooltip-arrow, +[uib-tooltip-popup].tooltip.bottom-left > .tooltip-arrow, +[uib-tooltip-popup].tooltip.bottom-right > .tooltip-arrow, +[uib-tooltip-popup].tooltip.left-top > .tooltip-arrow, +[uib-tooltip-popup].tooltip.left-bottom > .tooltip-arrow, +[uib-tooltip-popup].tooltip.right-top > .tooltip-arrow, +[uib-tooltip-popup].tooltip.right-bottom > .tooltip-arrow, +[uib-tooltip-html-popup].tooltip.top-left > .tooltip-arrow, +[uib-tooltip-html-popup].tooltip.top-right > .tooltip-arrow, +[uib-tooltip-html-popup].tooltip.bottom-left > .tooltip-arrow, +[uib-tooltip-html-popup].tooltip.bottom-right > .tooltip-arrow, +[uib-tooltip-html-popup].tooltip.left-top > .tooltip-arrow, +[uib-tooltip-html-popup].tooltip.left-bottom > .tooltip-arrow, +[uib-tooltip-html-popup].tooltip.right-top > .tooltip-arrow, +[uib-tooltip-html-popup].tooltip.right-bottom > .tooltip-arrow, +[uib-tooltip-template-popup].tooltip.top-left > .tooltip-arrow, +[uib-tooltip-template-popup].tooltip.top-right > .tooltip-arrow, +[uib-tooltip-template-popup].tooltip.bottom-left > .tooltip-arrow, +[uib-tooltip-template-popup].tooltip.bottom-right > .tooltip-arrow, +[uib-tooltip-template-popup].tooltip.left-top > .tooltip-arrow, +[uib-tooltip-template-popup].tooltip.left-bottom > .tooltip-arrow, +[uib-tooltip-template-popup].tooltip.right-top > .tooltip-arrow, +[uib-tooltip-template-popup].tooltip.right-bottom > .tooltip-arrow, +[uib-popover-popup].popover.top-left > .arrow, +[uib-popover-popup].popover.top-right > .arrow, +[uib-popover-popup].popover.bottom-left > .arrow, +[uib-popover-popup].popover.bottom-right > .arrow, +[uib-popover-popup].popover.left-top > .arrow, +[uib-popover-popup].popover.left-bottom > .arrow, +[uib-popover-popup].popover.right-top > .arrow, +[uib-popover-popup].popover.right-bottom > .arrow, +[uib-popover-html-popup].popover.top-left > .arrow, +[uib-popover-html-popup].popover.top-right > .arrow, +[uib-popover-html-popup].popover.bottom-left > .arrow, +[uib-popover-html-popup].popover.bottom-right > .arrow, +[uib-popover-html-popup].popover.left-top > .arrow, +[uib-popover-html-popup].popover.left-bottom > .arrow, +[uib-popover-html-popup].popover.right-top > .arrow, +[uib-popover-html-popup].popover.right-bottom > .arrow, +[uib-popover-template-popup].popover.top-left > .arrow, +[uib-popover-template-popup].popover.top-right > .arrow, +[uib-popover-template-popup].popover.bottom-left > .arrow, +[uib-popover-template-popup].popover.bottom-right > .arrow, +[uib-popover-template-popup].popover.left-top > .arrow, +[uib-popover-template-popup].popover.left-bottom > .arrow, +[uib-popover-template-popup].popover.right-top > .arrow, +[uib-popover-template-popup].popover.right-bottom > .arrow { + top: auto; + bottom: auto; + left: auto; + right: auto; + margin: 0; +} + +[uib-popover-popup].popover, +[uib-popover-html-popup].popover, +[uib-popover-template-popup].popover { + display: block !important; +} diff --git a/src/tooltip/tooltip.js b/src/tooltip/tooltip.js index 28eb9e513e..dd02a077a1 100644 --- a/src/tooltip/tooltip.js +++ b/src/tooltip/tooltip.js @@ -3,30 +3,35 @@ * function, placement as a function, inside, support for more triggers than * just mouse enter/leave, html tooltips, and selector delegation. */ -angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position' ] ) +angular.module('ui.bootstrap.tooltip', ['ui.bootstrap.position', 'ui.bootstrap.stackedMap']) /** * The $tooltip service creates tooltip- and popover-like directives as well as * houses global options for them. */ -.provider( '$tooltip', function () { +.provider('$uibTooltip', function() { // The default options tooltip and popover. var defaultOptions = { placement: 'top', + placementClassPrefix: '', animation: true, - popupDelay: 0 + popupDelay: 0, + popupCloseDelay: 0, + useContentExp: false }; // Default hide triggers for each show trigger var triggerMap = { 'mouseenter': 'mouseleave', 'click': 'click', - 'focus': 'blur' + 'outsideClick': 'outsideClick', + 'focus': 'blur', + 'none': '' }; // The options specified to the provider globally. var globalOptions = {}; - + /** * `options({})` allows global configuration of all tooltips in the * application. @@ -36,23 +41,23 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position' ] ) * $tooltipProvider.options( { placement: 'left' } ); * }); */ - this.options = function( value ) { - angular.extend( globalOptions, value ); + this.options = function(value) { + angular.extend(globalOptions, value); }; /** * This allows you to extend the set of trigger mappings available. E.g.: * - * $tooltipProvider.setTriggers( 'openTrigger': 'closeTrigger' ); + * $tooltipProvider.setTriggers( { 'openTrigger': 'closeTrigger' } ); */ - this.setTriggers = function setTriggers ( triggers ) { - angular.extend( triggerMap, triggers ); + this.setTriggers = function setTriggers(triggers) { + angular.extend(triggerMap, triggers); }; /** - * This is a helper function for translating camel-case to snake-case. + * This is a helper function for translating camel-case to snake_case. */ - function snake_case(name){ + function snake_case(name) { var regexp = /[A-Z]/g; var separator = '-'; return name.replace(regexp, function(letter, pos) { @@ -64,9 +69,26 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position' ] ) * Returns the actual instance of the $tooltip service. * TODO support multiple triggers */ - this.$get = [ '$window', '$compile', '$timeout', '$parse', '$document', '$position', '$interpolate', function ( $window, $compile, $timeout, $parse, $document, $position, $interpolate ) { - return function $tooltip ( type, prefix, defaultTriggerShow ) { - var options = angular.extend( {}, defaultOptions, globalOptions ); + this.$get = ['$window', '$compile', '$timeout', '$document', '$uibPosition', '$interpolate', '$rootScope', '$parse', '$$stackedMap', function($window, $compile, $timeout, $document, $position, $interpolate, $rootScope, $parse, $$stackedMap) { + var openedTooltips = $$stackedMap.createNew(); + $document.on('keyup', keypressListener); + + $rootScope.$on('$destroy', function() { + $document.off('keyup', keypressListener); + }); + + function keypressListener(e) { + if (e.which === 27) { + var last = openedTooltips.top(); + if (last) { + last.value.close(); + last = null; + } + } + } + + return function $tooltip(ttType, prefix, defaultTriggerShow, options) { + options = angular.extend({}, defaultOptions, globalOptions, options); /** * Returns an object of show and hide triggers. @@ -82,263 +104,622 @@ angular.module( 'ui.bootstrap.tooltip', [ 'ui.bootstrap.position' ] ) * undefined; otherwise, it uses the `triggerMap` value of the show * trigger; else it will just use the show trigger. */ - function getTriggers ( trigger ) { - var show = trigger || options.trigger || defaultTriggerShow; - var hide = triggerMap[show] || show; + function getTriggers(trigger) { + var show = (trigger || options.trigger || defaultTriggerShow).split(' '); + var hide = show.map(function(trigger) { + return triggerMap[trigger] || trigger; + }); return { show: show, hide: hide }; } - var directiveName = snake_case( type ); + var directiveName = snake_case(ttType); var startSym = $interpolate.startSymbol(); var endSym = $interpolate.endSymbol(); - var template = - '<'+ directiveName +'-popup '+ - 'title="'+startSym+'tt_title'+endSym+'" '+ - 'content="'+startSym+'tt_content'+endSym+'" '+ - 'placement="'+startSym+'tt_placement'+endSym+'" '+ - 'animation="tt_animation()" '+ - 'is-open="tt_isOpen"'+ - '>'+ - ''+ directiveName +'-popup>'; + var template = + '' + + ''; return { - restrict: 'EA', - scope: true, - link: function link ( scope, element, attrs ) { - var tooltip = $compile( template )( scope ); - var transitionTimeout; - var popupTimeout; - var $body; - var appendToBody = angular.isDefined( options.appendToBody ) ? options.appendToBody : false; - var triggers = getTriggers( undefined ); - var hasRegisteredTriggers = false; - - // By default, the tooltip is not open. - // TODO add ability to start tooltip opened - scope.tt_isOpen = false; - - function toggleTooltipBind () { - if ( ! scope.tt_isOpen ) { - showTooltipBind(); - } else { - hideTooltipBind(); + compile: function(tElem, tAttrs) { + var tooltipLinker = $compile(template); + + return function link(scope, element, attrs, tooltipCtrl) { + var tooltip; + var tooltipLinkedScope; + var transitionTimeout; + var showTimeout; + var hideTimeout; + var positionTimeout; + var adjustmentTimeout; + var appendToBody = angular.isDefined(options.appendToBody) ? options.appendToBody : false; + var triggers = getTriggers(undefined); + var hasEnableExp = angular.isDefined(attrs[prefix + 'Enable']); + var ttScope = scope.$new(true); + var repositionScheduled = false; + var isOpenParse = angular.isDefined(attrs[prefix + 'IsOpen']) ? $parse(attrs[prefix + 'IsOpen']) : false; + var contentParse = options.useContentExp ? $parse(attrs[ttType]) : false; + var observers = []; + var lastPlacement; + + var positionTooltip = function() { + // check if tooltip exists and is not empty + if (!tooltip || !tooltip.html()) { return; } + + if (!positionTimeout) { + positionTimeout = $timeout(function() { + var ttPosition = $position.positionElements(element, tooltip, ttScope.placement, appendToBody); + var initialHeight = angular.isDefined(tooltip.offsetHeight) ? tooltip.offsetHeight : tooltip.prop('offsetHeight'); + var elementPos = appendToBody ? $position.offset(element) : $position.position(element); + tooltip.css({ top: ttPosition.top + 'px', left: ttPosition.left + 'px' }); + var placementClasses = ttPosition.placement.split('-'); + + if (!tooltip.hasClass(placementClasses[0])) { + tooltip.removeClass(lastPlacement.split('-')[0]); + tooltip.addClass(placementClasses[0]); + } + + if (!tooltip.hasClass(options.placementClassPrefix + ttPosition.placement)) { + tooltip.removeClass(options.placementClassPrefix + lastPlacement); + tooltip.addClass(options.placementClassPrefix + ttPosition.placement); + } + + adjustmentTimeout = $timeout(function() { + var currentHeight = angular.isDefined(tooltip.offsetHeight) ? tooltip.offsetHeight : tooltip.prop('offsetHeight'); + var adjustment = $position.adjustTop(placementClasses, elementPos, initialHeight, currentHeight); + if (adjustment) { + tooltip.css(adjustment); + } + adjustmentTimeout = null; + }, 0, false); + + // first time through tt element will have the + // uib-position-measure class or if the placement + // has changed we need to position the arrow. + if (tooltip.hasClass('uib-position-measure')) { + $position.positionArrow(tooltip, ttPosition.placement); + tooltip.removeClass('uib-position-measure'); + } else if (lastPlacement !== ttPosition.placement) { + $position.positionArrow(tooltip, ttPosition.placement); + } + lastPlacement = ttPosition.placement; + + positionTimeout = null; + }, 0, false); + } + }; + + // Set up the correct scope to allow transclusion later + ttScope.origScope = scope; + + // By default, the tooltip is not open. + // TODO add ability to start tooltip opened + ttScope.isOpen = false; + + function toggleTooltipBind() { + if (!ttScope.isOpen) { + showTooltipBind(); + } else { + hideTooltipBind(); + } } - } - - // Show the tooltip with delay if specified, otherwise show it immediately - function showTooltipBind() { - if ( scope.tt_popupDelay ) { - popupTimeout = $timeout( show, scope.tt_popupDelay ); - } else { - scope.$apply( show ); + + // Show the tooltip with delay if specified, otherwise show it immediately + function showTooltipBind() { + if (hasEnableExp && !scope.$eval(attrs[prefix + 'Enable'])) { + return; + } + + cancelHide(); + prepareTooltip(); + + if (ttScope.popupDelay) { + // Do nothing if the tooltip was already scheduled to pop-up. + // This happens if show is triggered multiple times before any hide is triggered. + if (!showTimeout) { + showTimeout = $timeout(show, ttScope.popupDelay, false); + } + } else { + show(); + } } - } - function hideTooltipBind () { - scope.$apply(function () { - hide(); - }); - } - - // Show the tooltip popup element. - function show() { - var position, - ttWidth, - ttHeight, - ttPosition; - - // Don't show empty tooltips. - if ( ! scope.tt_content ) { - return; + function hideTooltipBind() { + cancelShow(); + + if (ttScope.popupCloseDelay) { + if (!hideTimeout) { + hideTimeout = $timeout(hide, ttScope.popupCloseDelay, false); + } + } else { + hide(); + } } - // If there is a pending remove transition, we must cancel it, lest the - // tooltip be mysteriously removed. - if ( transitionTimeout ) { - $timeout.cancel( transitionTimeout ); + // Show the tooltip popup element. + function show() { + cancelShow(); + cancelHide(); + + // Don't show empty tooltips. + if (!ttScope.content) { + return angular.noop; + } + + createTooltip(); + + // And show the tooltip. + ttScope.$evalAsync(function() { + ttScope.isOpen = true; + assignIsOpen(true); + positionTooltip(); + }); } - - // Set the initial positioning. - tooltip.css({ top: 0, left: 0, display: 'block' }); - - // Now we add it to the DOM because need some info about it. But it's not - // visible yet anyway. - if ( appendToBody ) { - $body = $body || $document.find( 'body' ); - $body.append( tooltip ); - } else { - element.after( tooltip ); + + function cancelShow() { + if (showTimeout) { + $timeout.cancel(showTimeout); + showTimeout = null; + } + + if (positionTimeout) { + $timeout.cancel(positionTimeout); + positionTimeout = null; + } } - // Get the position of the directive element. - position = appendToBody ? $position.offset( element ) : $position.position( element ); - - // Get the height and width of the tooltip so we can center it. - ttWidth = tooltip.prop( 'offsetWidth' ); - ttHeight = tooltip.prop( 'offsetHeight' ); - - // Calculate the tooltip's top and left coordinates to center it with - // this directive. - switch ( scope.tt_placement ) { - case 'mouse': - var mousePos = $position.mouse(); - ttPosition = { - top: mousePos.y, - left: mousePos.x - }; - break; - case 'right': - ttPosition = { - top: position.top + position.height / 2 - ttHeight / 2, - left: position.left + position.width - }; - break; - case 'bottom': - ttPosition = { - top: position.top + position.height, - left: position.left + position.width / 2 - ttWidth / 2 - }; - break; - case 'left': - ttPosition = { - top: position.top + position.height / 2 - ttHeight / 2, - left: position.left - ttWidth - }; - break; - default: - ttPosition = { - top: position.top - ttHeight, - left: position.left + position.width / 2 - ttWidth / 2 - }; - break; + // Hide the tooltip popup element. + function hide() { + if (!ttScope) { + return; + } + + // First things first: we don't show it anymore. + ttScope.$evalAsync(function() { + if (ttScope) { + ttScope.isOpen = false; + assignIsOpen(false); + // And now we remove it from the DOM. However, if we have animation, we + // need to wait for it to expire beforehand. + // FIXME: this is a placeholder for a port of the transitions library. + // The fade transition in TWBS is 150ms. + if (ttScope.animation) { + if (!transitionTimeout) { + transitionTimeout = $timeout(removeTooltip, 150, false); + } + } else { + removeTooltip(); + } + } + }); } - ttPosition.top += 'px'; - ttPosition.left += 'px'; + function cancelHide() { + if (hideTimeout) { + $timeout.cancel(hideTimeout); + hideTimeout = null; + } - // Now set the calculated positioning. - tooltip.css( ttPosition ); - - // And show the tooltip. - scope.tt_isOpen = true; - } - - // Hide the tooltip popup element. - function hide() { - // First things first: we don't show it anymore. - scope.tt_isOpen = false; - - //if tooltip is going to be shown after delay, we must cancel this - $timeout.cancel( popupTimeout ); - - // And now we remove it from the DOM. However, if we have animation, we - // need to wait for it to expire beforehand. - // FIXME: this is a placeholder for a port of the transitions library. - if ( angular.isDefined( scope.tt_animation ) && scope.tt_animation() ) { - transitionTimeout = $timeout( function () { tooltip.remove(); }, 500 ); - } else { - tooltip.remove(); + if (transitionTimeout) { + $timeout.cancel(transitionTimeout); + transitionTimeout = null; + } } - } - - /** - * Observe the relevant attributes. - */ - attrs.$observe( type, function ( val ) { - scope.tt_content = val; - }); - attrs.$observe( prefix+'Title', function ( val ) { - scope.tt_title = val; - }); + function createTooltip() { + // There can only be one tooltip element per directive shown at once. + if (tooltip) { + return; + } + + tooltipLinkedScope = ttScope.$new(); + tooltip = tooltipLinker(tooltipLinkedScope, function(tooltip) { + if (appendToBody) { + $document.find('body').append(tooltip); + } else { + element.after(tooltip); + } + }); + + openedTooltips.add(ttScope, { + close: hide + }); + + prepObservers(); + } - attrs.$observe( prefix+'Placement', function ( val ) { - scope.tt_placement = angular.isDefined( val ) ? val : options.placement; - }); + function removeTooltip() { + cancelShow(); + cancelHide(); + unregisterObservers(); + + if (tooltip) { + tooltip.remove(); + + tooltip = null; + if (adjustmentTimeout) { + $timeout.cancel(adjustmentTimeout); + } + } + + openedTooltips.remove(ttScope); + + if (tooltipLinkedScope) { + tooltipLinkedScope.$destroy(); + tooltipLinkedScope = null; + } + } - attrs.$observe( prefix+'Animation', function ( val ) { - scope.tt_animation = angular.isDefined( val ) ? $parse( val ) : function(){ return options.animation; }; - }); + /** + * Set the initial scope values. Once + * the tooltip is created, the observers + * will be added to keep things in sync. + */ + function prepareTooltip() { + ttScope.title = attrs[prefix + 'Title']; + if (contentParse) { + ttScope.content = contentParse(scope); + } else { + ttScope.content = attrs[ttType]; + } + + ttScope.popupClass = attrs[prefix + 'Class']; + ttScope.placement = angular.isDefined(attrs[prefix + 'Placement']) ? attrs[prefix + 'Placement'] : options.placement; + var placement = $position.parsePlacement(ttScope.placement); + lastPlacement = placement[1] ? placement[0] + '-' + placement[1] : placement[0]; + + var delay = parseInt(attrs[prefix + 'PopupDelay'], 10); + var closeDelay = parseInt(attrs[prefix + 'PopupCloseDelay'], 10); + ttScope.popupDelay = !isNaN(delay) ? delay : options.popupDelay; + ttScope.popupCloseDelay = !isNaN(closeDelay) ? closeDelay : options.popupCloseDelay; + } - attrs.$observe( prefix+'PopupDelay', function ( val ) { - var delay = parseInt( val, 10 ); - scope.tt_popupDelay = ! isNaN(delay) ? delay : options.popupDelay; - }); + function assignIsOpen(isOpen) { + if (isOpenParse && angular.isFunction(isOpenParse.assign)) { + isOpenParse.assign(scope, isOpen); + } + } - attrs.$observe( prefix+'Trigger', function ( val ) { + ttScope.contentExp = function() { + return ttScope.content; + }; + + /** + * Observe the relevant attributes. + */ + attrs.$observe('disabled', function(val) { + if (val) { + cancelShow(); + } + + if (val && ttScope.isOpen) { + hide(); + } + }); - if (hasRegisteredTriggers) { - element.unbind( triggers.show, showTooltipBind ); - element.unbind( triggers.hide, hideTooltipBind ); + if (isOpenParse) { + scope.$watch(isOpenParse, function(val) { + if (ttScope && !val === ttScope.isOpen) { + toggleTooltipBind(); + } + }); } - triggers = getTriggers( val ); + function prepObservers() { + observers.length = 0; + + if (contentParse) { + observers.push( + scope.$watch(contentParse, function(val) { + ttScope.content = val; + if (!val && ttScope.isOpen) { + hide(); + } + }) + ); + + observers.push( + tooltipLinkedScope.$watch(function() { + if (!repositionScheduled) { + repositionScheduled = true; + tooltipLinkedScope.$$postDigest(function() { + repositionScheduled = false; + if (ttScope && ttScope.isOpen) { + positionTooltip(); + } + }); + } + }) + ); + } else { + observers.push( + attrs.$observe(ttType, function(val) { + ttScope.content = val; + if (!val && ttScope.isOpen) { + hide(); + } else { + positionTooltip(); + } + }) + ); + } + + observers.push( + attrs.$observe(prefix + 'Title', function(val) { + ttScope.title = val; + if (ttScope.isOpen) { + positionTooltip(); + } + }) + ); + + observers.push( + attrs.$observe(prefix + 'Placement', function(val) { + ttScope.placement = val ? val : options.placement; + if (ttScope.isOpen) { + positionTooltip(); + } + }) + ); + } - if ( triggers.show === triggers.hide ) { - element.bind( triggers.show, toggleTooltipBind ); - } else { - element.bind( triggers.show, showTooltipBind ); - element.bind( triggers.hide, hideTooltipBind ); + function unregisterObservers() { + if (observers.length) { + angular.forEach(observers, function(observer) { + observer(); + }); + observers.length = 0; + } } - hasRegisteredTriggers = true; - }); + // hide tooltips/popovers for outsideClick trigger + function bodyHideTooltipBind(e) { + if (!ttScope || !ttScope.isOpen || !tooltip) { + return; + } + // make sure the tooltip/popover link or tool tooltip/popover itself were not clicked + if (!element[0].contains(e.target) && !tooltip[0].contains(e.target)) { + hideTooltipBind(); + } + } - attrs.$observe( prefix+'AppendToBody', function ( val ) { - appendToBody = angular.isDefined( val ) ? $parse( val )( scope ) : appendToBody; - }); + // KeyboardEvent handler to hide the tooltip on Escape key press + function hideOnEscapeKey(e) { + if (e.which === 27) { + hideTooltipBind(); + } + } - // if a tooltip is attached to we need to remove it on - // location change as its parent scope will probably not be destroyed - // by the change. - if ( appendToBody ) { - scope.$on('$locationChangeSuccess', function closeTooltipOnLocationChangeSuccess () { - if ( scope.tt_isOpen ) { - hide(); + var unregisterTriggers = function() { + triggers.show.forEach(function(trigger) { + if (trigger === 'outsideClick') { + element.off('click', toggleTooltipBind); + } else { + element.off(trigger, showTooltipBind); + element.off(trigger, toggleTooltipBind); + } + element.off('keypress', hideOnEscapeKey); + }); + triggers.hide.forEach(function(trigger) { + if (trigger === 'outsideClick') { + $document.off('click', bodyHideTooltipBind); + } else { + element.off(trigger, hideTooltipBind); + } + }); + }; + + function prepTriggers() { + var showTriggers = [], hideTriggers = []; + var val = scope.$eval(attrs[prefix + 'Trigger']); + unregisterTriggers(); + + if (angular.isObject(val)) { + Object.keys(val).forEach(function(key) { + showTriggers.push(key); + hideTriggers.push(val[key]); + }); + triggers = { + show: showTriggers, + hide: hideTriggers + }; + } else { + triggers = getTriggers(val); + } + + if (triggers.show !== 'none') { + triggers.show.forEach(function(trigger, idx) { + if (trigger === 'outsideClick') { + element.on('click', toggleTooltipBind); + $document.on('click', bodyHideTooltipBind); + } else if (trigger === triggers.hide[idx]) { + element.on(trigger, toggleTooltipBind); + } else if (trigger) { + element.on(trigger, showTooltipBind); + element.on(triggers.hide[idx], hideTooltipBind); + } + element.on('keypress', hideOnEscapeKey); + }); + } } - }); - } - // Make sure tooltip is destroyed and removed. - scope.$on('$destroy', function onDestroyTooltip() { - if ( scope.tt_isOpen ) { - hide(); + prepTriggers(); + + var animation = scope.$eval(attrs[prefix + 'Animation']); + ttScope.animation = angular.isDefined(animation) ? !!animation : options.animation; + + var appendToBodyVal; + var appendKey = prefix + 'AppendToBody'; + if (appendKey in attrs && attrs[appendKey] === undefined) { + appendToBodyVal = true; } else { - tooltip.remove(); + appendToBodyVal = scope.$eval(attrs[appendKey]); } - }); + + appendToBody = angular.isDefined(appendToBodyVal) ? appendToBodyVal : appendToBody; + + // Make sure tooltip is destroyed and removed. + scope.$on('$destroy', function onDestroyTooltip() { + unregisterTriggers(); + removeTooltip(); + ttScope = null; + }); + }; } }; }; }]; }) -.directive( 'tooltipPopup', function () { +// This is mostly ngInclude code but with a custom scope +.directive('uibTooltipTemplateTransclude', [ + '$animate', '$sce', '$compile', '$templateRequest', +function ($animate, $sce, $compile, $templateRequest) { + return { + link: function(scope, elem, attrs) { + var origScope = scope.$eval(attrs.tooltipTemplateTranscludeScope); + + var changeCounter = 0, + currentScope, + previousElement, + currentElement; + + var cleanupLastIncludeContent = function() { + if (previousElement) { + previousElement.remove(); + previousElement = null; + } + + if (currentScope) { + currentScope.$destroy(); + currentScope = null; + } + + if (currentElement) { + $animate.leave(currentElement).then(function() { + previousElement = null; + }); + previousElement = currentElement; + currentElement = null; + } + }; + + scope.$watch($sce.parseAsResourceUrl(attrs.uibTooltipTemplateTransclude), function(src) { + var thisChangeId = ++changeCounter; + + if (src) { + //set the 2nd param to true to ignore the template request error so that the inner + //contents and scope can be cleaned up. + $templateRequest(src, true).then(function(response) { + if (thisChangeId !== changeCounter) { return; } + var newScope = origScope.$new(); + var template = response; + + var clone = $compile(template)(newScope, function(clone) { + cleanupLastIncludeContent(); + $animate.enter(clone, elem); + }); + + currentScope = newScope; + currentElement = clone; + + currentScope.$emit('$includeContentLoaded', src); + }, function() { + if (thisChangeId === changeCounter) { + cleanupLastIncludeContent(); + scope.$emit('$includeContentError', src); + } + }); + scope.$emit('$includeContentRequested', src); + } else { + cleanupLastIncludeContent(); + } + }); + + scope.$on('$destroy', cleanupLastIncludeContent); + } + }; +}]) + +/** + * Note that it's intentional that these classes are *not* applied through $animate. + * They must not be animated as they're expected to be present on the tooltip on + * initialization. + */ +.directive('uibTooltipClasses', ['$uibPosition', function($uibPosition) { + return { + restrict: 'A', + link: function(scope, element, attrs) { + // need to set the primary position so the + // arrow has space during position measure. + // tooltip.positionTooltip() + if (scope.placement) { + // // There are no top-left etc... classes + // // in TWBS, so we need the primary position. + var position = $uibPosition.parsePlacement(scope.placement); + element.addClass(position[0]); + } + + if (scope.popupClass) { + element.addClass(scope.popupClass); + } + + if (scope.animation) { + element.addClass(attrs.tooltipAnimationClass); + } + } + }; +}]) + +.directive('uibTooltipPopup', function() { + return { + restrict: 'A', + scope: { content: '@' }, + templateUrl: 'uib/template/tooltip/tooltip-popup.html' + }; +}) + +.directive('uibTooltip', [ '$uibTooltip', function($uibTooltip) { + return $uibTooltip('uibTooltip', 'tooltip', 'mouseenter'); +}]) + +.directive('uibTooltipTemplatePopup', function() { return { - restrict: 'E', - replace: true, - scope: { content: '@', placement: '@', animation: '&', isOpen: '&' }, - templateUrl: 'template/tooltip/tooltip-popup.html' + restrict: 'A', + scope: { contentExp: '&', originScope: '&' }, + templateUrl: 'uib/template/tooltip/tooltip-template-popup.html' }; }) -.directive( 'tooltip', [ '$tooltip', function ( $tooltip ) { - return $tooltip( 'tooltip', 'tooltip', 'mouseenter' ); +.directive('uibTooltipTemplate', ['$uibTooltip', function($uibTooltip) { + return $uibTooltip('uibTooltipTemplate', 'tooltip', 'mouseenter', { + useContentExp: true + }); }]) -.directive( 'tooltipHtmlUnsafePopup', function () { +.directive('uibTooltipHtmlPopup', function() { return { - restrict: 'E', - replace: true, - scope: { content: '@', placement: '@', animation: '&', isOpen: '&' }, - templateUrl: 'template/tooltip/tooltip-html-unsafe-popup.html' + restrict: 'A', + scope: { contentExp: '&' }, + templateUrl: 'uib/template/tooltip/tooltip-html-popup.html' }; }) -.directive( 'tooltipHtmlUnsafe', [ '$tooltip', function ( $tooltip ) { - return $tooltip( 'tooltipHtmlUnsafe', 'tooltip', 'mouseenter' ); +.directive('uibTooltipHtml', ['$uibTooltip', function($uibTooltip) { + return $uibTooltip('uibTooltipHtml', 'tooltip', 'mouseenter', { + useContentExp: true + }); }]); diff --git a/src/transition/test/transition.spec.js b/src/transition/test/transition.spec.js deleted file mode 100644 index 34f744b037..0000000000 --- a/src/transition/test/transition.spec.js +++ /dev/null @@ -1,147 +0,0 @@ - -describe('$transition', function() { - - // Work out if we are running IE - var ie = (function(){ - var v = 3, - div = document.createElement('div'), - all = div.getElementsByTagName('i'); - do { - div.innerHTML = ''; - } while(all[0]); - return v > 4 ? v : undefined; - }()); - - var $transition, $timeout; - - beforeEach(module('ui.bootstrap.transition')); - - beforeEach(inject(function(_$transition_, _$timeout_) { - $transition = _$transition_; - $timeout = _$timeout_; - })); - - it('returns our custom promise', function() { - var element = angular.element(''); - var promise = $transition(element, ''); - expect(promise.then).toEqual(jasmine.any(Function)); - expect(promise.cancel).toEqual(jasmine.any(Function)); - }); - - it('changes the css if passed a string', function() { - var element = angular.element(''); - spyOn(element, 'addClass'); - $transition(element, 'triggerClass'); - $timeout.flush(); - - expect(element.addClass).toHaveBeenCalledWith('triggerClass'); - }); - - it('changes the style if passed an object', function() { - var element = angular.element(''); - var triggerStyle = { height: '11px' }; - spyOn(element, 'css'); - $transition(element, triggerStyle); - $timeout.flush(); - expect(element.css).toHaveBeenCalledWith(triggerStyle); - }); - - it('calls the function if passed', function() { - var element = angular.element(''); - var triggerFunction = jasmine.createSpy('triggerFunction'); - $transition(element, triggerFunction); - $timeout.flush(); - expect(triggerFunction).toHaveBeenCalledWith(element); - }); - - // Versions of Internet Explorer before version 10 do not have CSS transitions - if ( !ie || ie > 9 ) { - describe('transitionEnd event', function() { - var element, triggerTransitionEnd; - - beforeEach(function() { - element = angular.element(''); - // Mock up the element.bind method - spyOn(element, 'bind').andCallFake(function(element, handler) { - // Store the handler to be used to simulate the end of the transition later - triggerTransitionEnd = handler; - }); - // Mock up the element.unbind method - spyOn(element, 'unbind'); - }); - - describe('transitionEndEventName', function() { - it('should be a string ending with transitionend', function() { - expect($transition.transitionEndEventName).toMatch(/transitionend$/i); - }); - }); - - describe('animationEndEventName', function() { - it('should be a string ending with animationend', function() { - expect($transition.animationEndEventName).toMatch(/animationend$/i); - }); - }); - - it('binds a transitionEnd handler to the element', function() { - $transition(element, ''); - expect(element.bind).toHaveBeenCalledWith($transition.transitionEndEventName, jasmine.any(Function)); - }); - - it('binds an animationEnd handler to the element if option is given', function() { - $transition(element, '', {animation: true}); - expect(element.bind).toHaveBeenCalledWith($transition.animationEndEventName, jasmine.any(Function)); - }); - - it('resolves the promise when the transitionEnd is triggered', function() { - var resolutionHandler = jasmine.createSpy('resolutionHandler'); - - // Run the transition - $transition(element, '').then(resolutionHandler); - - // Simulate the end of transition event - triggerTransitionEnd(); - $timeout.flush(); - - expect(resolutionHandler).toHaveBeenCalledWith(element); - }); - - it('rejects the promise if transition is cancelled', function() { - var rejectionHandler = jasmine.createSpy('rejectionHandler'); - - var promise = $transition(element, ''); - promise.then(null, rejectionHandler); - - promise.cancel(); - inject(function($rootScope) { - $rootScope.$digest(); - }); - expect(rejectionHandler).toHaveBeenCalledWith(jasmine.any(String)); - expect(element.unbind).toHaveBeenCalledWith($transition.transitionEndEventName, jasmine.any(Function)); - }); - }); - } else { - - describe('transitionEndEventName', function() { - it('should be undefined', function() { - expect($transition.transitionEndEventName).not.toBeDefined(); - }); - }); - - it('does not bind a transitionEnd handler to the element', function() { - var element = angular.element(''); - spyOn(element, 'bind'); - $transition(element, ''); - expect(element.bind).not.toHaveBeenCalledWith($transition.transitionEndEventName, jasmine.any(Function)); - }); - - it('resolves the promise', function() { - var element = angular.element(''); - var transitionEndHandler = jasmine.createSpy('transitionEndHandler'); - $transition(element, '').then(transitionEndHandler); - $timeout.flush(); - expect(transitionEndHandler).toHaveBeenCalledWith(element); - }); - - } -}); - diff --git a/src/transition/transition.js b/src/transition/transition.js deleted file mode 100644 index c23d3f76f7..0000000000 --- a/src/transition/transition.js +++ /dev/null @@ -1,82 +0,0 @@ -angular.module('ui.bootstrap.transition', []) - -/** - * $transition service provides a consistent interface to trigger CSS 3 transitions and to be informed when they complete. - * @param {DOMElement} element The DOMElement that will be animated. - * @param {string|object|function} trigger The thing that will cause the transition to start: - * - As a string, it represents the css class to be added to the element. - * - As an object, it represents a hash of style attributes to be applied to the element. - * - As a function, it represents a function to be called that will cause the transition to occur. - * @return {Promise} A promise that is resolved when the transition finishes. - */ -.factory('$transition', ['$q', '$timeout', '$rootScope', function($q, $timeout, $rootScope) { - - var $transition = function(element, trigger, options) { - options = options || {}; - var deferred = $q.defer(); - var endEventName = $transition[options.animation ? "animationEndEventName" : "transitionEndEventName"]; - - var transitionEndHandler = function(event) { - $rootScope.$apply(function() { - element.unbind(endEventName, transitionEndHandler); - deferred.resolve(element); - }); - }; - - if (endEventName) { - element.bind(endEventName, transitionEndHandler); - } - - // Wrap in a timeout to allow the browser time to update the DOM before the transition is to occur - $timeout(function() { - if ( angular.isString(trigger) ) { - element.addClass(trigger); - } else if ( angular.isFunction(trigger) ) { - trigger(element); - } else if ( angular.isObject(trigger) ) { - element.css(trigger); - } - //If browser does not support transitions, instantly resolve - if ( !endEventName ) { - deferred.resolve(element); - } - }); - - // Add our custom cancel function to the promise that is returned - // We can call this if we are about to run a new transition, which we know will prevent this transition from ending, - // i.e. it will therefore never raise a transitionEnd event for that transition - deferred.promise.cancel = function() { - if ( endEventName ) { - element.unbind(endEventName, transitionEndHandler); - } - deferred.reject('Transition cancelled'); - }; - - return deferred.promise; - }; - - // Work out the name of the transitionEnd event - var transElement = document.createElement('trans'); - var transitionEndEventNames = { - 'WebkitTransition': 'webkitTransitionEnd', - 'MozTransition': 'transitionend', - 'OTransition': 'oTransitionEnd', - 'transition': 'transitionend' - }; - var animationEndEventNames = { - 'WebkitTransition': 'webkitAnimationEnd', - 'MozTransition': 'animationend', - 'OTransition': 'oAnimationEnd', - 'transition': 'animationend' - }; - function findEndEventName(endEventNames) { - for (var name in endEventNames){ - if (transElement.style[name] !== undefined) { - return endEventNames[name]; - } - } - } - $transition.transitionEndEventName = findEndEventName(transitionEndEventNames); - $transition.animationEndEventName = findEndEventName(animationEndEventNames); - return $transition; -}]); diff --git a/src/typeahead/docs/demo.html b/src/typeahead/docs/demo.html index 5cc8f5ec83..34faf743fc 100644 --- a/src/typeahead/docs/demo.html +++ b/src/typeahead/docs/demo.html @@ -1,4 +1,78 @@ - - Model: {{selected| json}} - - \ No newline at end of file + + + + + + + + + Static arrays + Model: {{selected | json}} + + + Asynchronous results + Model: {{asyncSelected | json}} + + + + No Results Found + + + ngModelOptions support + Model: {{ngModelOptionsSelected | json}} + + + Custom templates for results + Model: {{customSelected | json}} + + + Custom popup templates for typeahead's dropdown + Model: {{customPopupSelected | json}} + + diff --git a/src/typeahead/docs/demo.js b/src/typeahead/docs/demo.js index db898236ce..bc06c53aa2 100644 --- a/src/typeahead/docs/demo.js +++ b/src/typeahead/docs/demo.js @@ -1,5 +1,38 @@ -function TypeaheadCtrl($scope) { +angular.module('ui.bootstrap.demo').controller('TypeaheadCtrl', function($scope, $http) { + + var _selected; $scope.selected = undefined; $scope.states = ['Alabama', 'Alaska', 'Arizona', 'Arkansas', 'California', 'Colorado', 'Connecticut', 'Delaware', 'Florida', 'Georgia', 'Hawaii', 'Idaho', 'Illinois', 'Indiana', 'Iowa', 'Kansas', 'Kentucky', 'Louisiana', 'Maine', 'Maryland', 'Massachusetts', 'Michigan', 'Minnesota', 'Mississippi', 'Missouri', 'Montana', 'Nebraska', 'Nevada', 'New Hampshire', 'New Jersey', 'New Mexico', 'New York', 'North Dakota', 'North Carolina', 'Ohio', 'Oklahoma', 'Oregon', 'Pennsylvania', 'Rhode Island', 'South Carolina', 'South Dakota', 'Tennessee', 'Texas', 'Utah', 'Vermont', 'Virginia', 'Washington', 'West Virginia', 'Wisconsin', 'Wyoming']; -} \ No newline at end of file + // Any function returning a promise object can be used to load values asynchronously + $scope.getLocation = function(val) { + return $http.get('//maps.googleapis.com/maps/api/geocode/json', { + params: { + address: val, + sensor: false + } + }).then(function(response){ + return response.data.results.map(function(item){ + return item.formatted_address; + }); + }); + }; + + $scope.ngModelOptionsSelected = function(value) { + if (arguments.length) { + _selected = value; + } else { + return _selected; + } + }; + + $scope.modelOptions = { + debounce: { + default: 500, + blur: 250 + }, + getterSetter: true + }; + + $scope.statesWithFlags = [{'name':'Alabama','flag':'5/5c/Flag_of_Alabama.svg/45px-Flag_of_Alabama.svg.png'},{'name':'Alaska','flag':'e/e6/Flag_of_Alaska.svg/43px-Flag_of_Alaska.svg.png'},{'name':'Arizona','flag':'9/9d/Flag_of_Arizona.svg/45px-Flag_of_Arizona.svg.png'},{'name':'Arkansas','flag':'9/9d/Flag_of_Arkansas.svg/45px-Flag_of_Arkansas.svg.png'},{'name':'California','flag':'0/01/Flag_of_California.svg/45px-Flag_of_California.svg.png'},{'name':'Colorado','flag':'4/46/Flag_of_Colorado.svg/45px-Flag_of_Colorado.svg.png'},{'name':'Connecticut','flag':'9/96/Flag_of_Connecticut.svg/39px-Flag_of_Connecticut.svg.png'},{'name':'Delaware','flag':'c/c6/Flag_of_Delaware.svg/45px-Flag_of_Delaware.svg.png'},{'name':'Florida','flag':'f/f7/Flag_of_Florida.svg/45px-Flag_of_Florida.svg.png'},{'name':'Georgia','flag':'5/54/Flag_of_Georgia_%28U.S._state%29.svg/46px-Flag_of_Georgia_%28U.S._state%29.svg.png'},{'name':'Hawaii','flag':'e/ef/Flag_of_Hawaii.svg/46px-Flag_of_Hawaii.svg.png'},{'name':'Idaho','flag':'a/a4/Flag_of_Idaho.svg/38px-Flag_of_Idaho.svg.png'},{'name':'Illinois','flag':'0/01/Flag_of_Illinois.svg/46px-Flag_of_Illinois.svg.png'},{'name':'Indiana','flag':'a/ac/Flag_of_Indiana.svg/45px-Flag_of_Indiana.svg.png'},{'name':'Iowa','flag':'a/aa/Flag_of_Iowa.svg/44px-Flag_of_Iowa.svg.png'},{'name':'Kansas','flag':'d/da/Flag_of_Kansas.svg/46px-Flag_of_Kansas.svg.png'},{'name':'Kentucky','flag':'8/8d/Flag_of_Kentucky.svg/46px-Flag_of_Kentucky.svg.png'},{'name':'Louisiana','flag':'e/e0/Flag_of_Louisiana.svg/46px-Flag_of_Louisiana.svg.png'},{'name':'Maine','flag':'3/35/Flag_of_Maine.svg/45px-Flag_of_Maine.svg.png'},{'name':'Maryland','flag':'a/a0/Flag_of_Maryland.svg/45px-Flag_of_Maryland.svg.png'},{'name':'Massachusetts','flag':'f/f2/Flag_of_Massachusetts.svg/46px-Flag_of_Massachusetts.svg.png'},{'name':'Michigan','flag':'b/b5/Flag_of_Michigan.svg/45px-Flag_of_Michigan.svg.png'},{'name':'Minnesota','flag':'b/b9/Flag_of_Minnesota.svg/46px-Flag_of_Minnesota.svg.png'},{'name':'Mississippi','flag':'4/42/Flag_of_Mississippi.svg/45px-Flag_of_Mississippi.svg.png'},{'name':'Missouri','flag':'5/5a/Flag_of_Missouri.svg/46px-Flag_of_Missouri.svg.png'},{'name':'Montana','flag':'c/cb/Flag_of_Montana.svg/45px-Flag_of_Montana.svg.png'},{'name':'Nebraska','flag':'4/4d/Flag_of_Nebraska.svg/46px-Flag_of_Nebraska.svg.png'},{'name':'Nevada','flag':'f/f1/Flag_of_Nevada.svg/45px-Flag_of_Nevada.svg.png'},{'name':'New Hampshire','flag':'2/28/Flag_of_New_Hampshire.svg/45px-Flag_of_New_Hampshire.svg.png'},{'name':'New Jersey','flag':'9/92/Flag_of_New_Jersey.svg/45px-Flag_of_New_Jersey.svg.png'},{'name':'New Mexico','flag':'c/c3/Flag_of_New_Mexico.svg/45px-Flag_of_New_Mexico.svg.png'},{'name':'New York','flag':'1/1a/Flag_of_New_York.svg/46px-Flag_of_New_York.svg.png'},{'name':'North Carolina','flag':'b/bb/Flag_of_North_Carolina.svg/45px-Flag_of_North_Carolina.svg.png'},{'name':'North Dakota','flag':'e/ee/Flag_of_North_Dakota.svg/38px-Flag_of_North_Dakota.svg.png'},{'name':'Ohio','flag':'4/4c/Flag_of_Ohio.svg/46px-Flag_of_Ohio.svg.png'},{'name':'Oklahoma','flag':'6/6e/Flag_of_Oklahoma.svg/45px-Flag_of_Oklahoma.svg.png'},{'name':'Oregon','flag':'b/b9/Flag_of_Oregon.svg/46px-Flag_of_Oregon.svg.png'},{'name':'Pennsylvania','flag':'f/f7/Flag_of_Pennsylvania.svg/45px-Flag_of_Pennsylvania.svg.png'},{'name':'Rhode Island','flag':'f/f3/Flag_of_Rhode_Island.svg/32px-Flag_of_Rhode_Island.svg.png'},{'name':'South Carolina','flag':'6/69/Flag_of_South_Carolina.svg/45px-Flag_of_South_Carolina.svg.png'},{'name':'South Dakota','flag':'1/1a/Flag_of_South_Dakota.svg/46px-Flag_of_South_Dakota.svg.png'},{'name':'Tennessee','flag':'9/9e/Flag_of_Tennessee.svg/46px-Flag_of_Tennessee.svg.png'},{'name':'Texas','flag':'f/f7/Flag_of_Texas.svg/45px-Flag_of_Texas.svg.png'},{'name':'Utah','flag':'f/f6/Flag_of_Utah.svg/45px-Flag_of_Utah.svg.png'},{'name':'Vermont','flag':'4/49/Flag_of_Vermont.svg/46px-Flag_of_Vermont.svg.png'},{'name':'Virginia','flag':'4/47/Flag_of_Virginia.svg/44px-Flag_of_Virginia.svg.png'},{'name':'Washington','flag':'5/54/Flag_of_Washington.svg/46px-Flag_of_Washington.svg.png'},{'name':'West Virginia','flag':'2/22/Flag_of_West_Virginia.svg/46px-Flag_of_West_Virginia.svg.png'},{'name':'Wisconsin','flag':'2/22/Flag_of_Wisconsin.svg/45px-Flag_of_Wisconsin.svg.png'},{'name':'Wyoming','flag':'b/bc/Flag_of_Wyoming.svg/43px-Flag_of_Wyoming.svg.png'}]; +}); diff --git a/src/typeahead/docs/readme.md b/src/typeahead/docs/readme.md index db0ea04ab4..efd2dfd0fc 100644 --- a/src/typeahead/docs/readme.md +++ b/src/typeahead/docs/readme.md @@ -1,50 +1,126 @@ -Typeahead is a AngularJS version of [Twitter Bootstrap typeahead plugin](http://twitter.github.com/bootstrap/javascript.html#typeahead). -This directive can be used to quickly create elegant typeheads with any form text input. +Typeahead is a AngularJS version of [Bootstrap v2's typeahead plugin](http://getbootstrap.com/2.3.2/javascript.html#typeahead). +This directive can be used to quickly create elegant typeaheads with any form text input. -It is very well integrated into the AngularJS as it uses subset of the -[select directive](http://docs.angularjs.org/api/ng.directive:select) syntax, which is very flexible. Supported expressions: +It is very well integrated into AngularJS as it uses a subset of the +[select directive](http://docs.angularjs.org/api/ng.directive:select) syntax, which is very flexible. Supported expressions are: * _label_ for _value_ in _sourceArray_ * _select_ as _label_ for _value_ in _sourceArray_ -The `sourceArray` expression can use a special `$viewValue` variable that corresponds to a value entered inside input by a user. +The `sourceArray` expression can use a special `$viewValue` variable that corresponds to the value entered inside the input. -Also this directive works with promises and it means that you can retrieve matches using the `$http` service with minimal effort. +This directive works with promises, meaning you can retrieve matches using the `$http` service with minimal effort. -The typeahead directives provide several attributes: +### uib-typeahead settings -* `ng-model` - : - Assignable angular expression to data-bind to +* `ng-model` + $ + - + Assignable angular expression to data-bind to. -* `typeahead` - : - Comprehension Angular expression (see [select directive](http://docs.angularjs.org/api/ng.directive:select)) +* `ng-model-options` + $ - + Options for ng-model (see [ng-model-options directive](https://docs.angularjs.org/api/ng/directive/ngModelOptions)). Currently supports the `debounce` and `getterSetter` options. -* `typeahead-editable` - _(Defaults: true)_ : - Should it restrict model values to the ones selected from the popup only ? +* `typeahead-append-to` + $ + _(Default: `null`)_ - + Should the typeahead popup be appended to an element instead of the parent element? -* `typeahead-input-formatter` - _(Defaults: undefined)_ : - Format the ng-model result after selection +* `typeahead-append-to-body` + $ + + _(Default: `false`)_ - + Should the typeahead popup be appended to $body instead of the parent element? -* `typeahead-loading` - _(Defaults: angular.noop)_ : - Binding to a variable that indicates if matches are being retrieved asynchronously +* `typeahead-editable` + $ + + _(Default: `true`)_ - + Should it restrict model values to the ones selected from the popup only? -* `typeahead-min-length` - _(Defaults: 1)_ : - Minimal no of characters that needs to be entered before typeahead kicks-in +* `typeahead-focus-first` + $ + _(Default: `true`)_ - + Should the first match automatically be focused as you type? -* `typeahead-on-select` - _(Defaults: null)_ : - A callback executed when a match is selected +* `typeahead-focus-on-select` + _(Default: `true`)_ - + On selection, focus the input element the typeahead directive is associated with. -* `typeahead-template-url` - : - Set custom item template +* `typeahead-input-formatter` + + _(Default: `undefined`)_ - + Format the ng-model result after selection. -* `typeahead-wait-ms` - _(Defaults: 0)_ : - Minimal wait time after last character typed before typehead kicks-in +* `typeahead-is-open` + $ + + _(Default: `angular.noop`)_ - + Binding to a variable that indicates if the dropdown is open. + +* `typeahead-loading` + $ + + _(Default: `angular.noop`)_ - + Binding to a variable that indicates if matches are being retrieved asynchronously. + +* `typeahead-min-length` + $ + + _(Default: `1`)_ - + Minimal no of characters that needs to be entered before typeahead kicks-in. Must be greater than or equal to 0. + +* `typeahead-no-results` + $ + + _(Default: `angular.noop`)_ - + Binding to a variable that indicates if no matching results were found. + +* `typeahead-should-select($event)` + $ + _(Default: `null`)_ - + A callback executed when a `keyup` event that might trigger a selection occurs. Selection will only occur if this function returns true. + +* `typeahead-on-select($item, $model, $label, $event)` + $ + _(Default: `null`)_ - + A callback executed when a match is selected. $event can be undefined if selection not triggered from a user event. + +* `typeahead-popup-template-url` + _(Default: `uib/template/typeahead/typeahead-popup.html`)_ - + Set custom popup template. + +* `typeahead-select-on-blur` + $ + _(Default: `false`)_ - + On blur, select the currently highlighted match. + +* `typeahead-select-on-exact` + $ + _(Default: `false`)_ - + Automatically select the item when it is the only one that exactly matches the user input. + +* `typeahead-show-hint` + $ + _(Default: `false`)_ - + Show hint when the first option matches. + +* `typeahead-template-url` + _(Default: `uib/template/typeahead/typeahead-match.html`)_ - + Set custom item template. + +* `typeahead-wait-ms` + $ + + _(Default: `0`)_ - + Minimal wait time after last character typed before typeahead kicks-in. + +* `uib-typeahead` + $ + - + Comprehension Angular expression (see [select directive](http://docs.angularjs.org/api/ng.directive:select)). + +**Notes** + +If a custom template for the popup is used, the wrapper selector used for the match items is the `uib-typeahead-match` class. diff --git a/src/typeahead/index-nocss.js b/src/typeahead/index-nocss.js new file mode 100644 index 0000000000..45f5d207be --- /dev/null +++ b/src/typeahead/index-nocss.js @@ -0,0 +1,11 @@ +require('../debounce'); +require('../position/index-nocss.js'); +require('../../template/typeahead/typeahead-match.html.js'); +require('../../template/typeahead/typeahead-popup.html.js'); +require('./typeahead'); + +var MODULE_NAME = 'ui.bootstrap.module.typeahead'; + +angular.module(MODULE_NAME, ['ui.bootstrap.typeahead', 'uib/template/typeahead/typeahead-match.html', 'uib/template/typeahead/typeahead-popup.html']); + +module.exports = MODULE_NAME; diff --git a/src/typeahead/index.js b/src/typeahead/index.js new file mode 100644 index 0000000000..440045986e --- /dev/null +++ b/src/typeahead/index.js @@ -0,0 +1,3 @@ +require('../position/position.css'); +require('./typeahead.css'); +module.exports = require('./index-nocss.js'); diff --git a/src/typeahead/test/typeahead-highlight-ngsanitize.spec.js b/src/typeahead/test/typeahead-highlight-ngsanitize.spec.js new file mode 100644 index 0000000000..de24319361 --- /dev/null +++ b/src/typeahead/test/typeahead-highlight-ngsanitize.spec.js @@ -0,0 +1,16 @@ +describe('Security concerns', function() { + var highlightFilter, $sanitize, logSpy; + + beforeEach(module('ui.bootstrap.typeahead', 'ngSanitize')); + + beforeEach(inject(function (uibTypeaheadHighlightFilter, _$sanitize_, $log) { + highlightFilter = uibTypeaheadHighlightFilter; + $sanitize = _$sanitize_; + logSpy = spyOn($log, 'warn'); + })); + + it('should not call the $log service when ngSanitize is present', function() { + highlightFilter('before after', 'match'); + expect(logSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/typeahead/test/typeahead-highlight.spec.js b/src/typeahead/test/typeahead-highlight.spec.js index edfffea685..4488c6d4ea 100644 --- a/src/typeahead/test/typeahead-highlight.spec.js +++ b/src/typeahead/test/typeahead-highlight.spec.js @@ -1,29 +1,51 @@ describe('typeaheadHighlight', function () { - var highlightFilter; + var highlightFilter, $log, $sce, logSpy; beforeEach(module('ui.bootstrap.typeahead')); - beforeEach(inject(function (typeaheadHighlightFilter) { - highlightFilter = typeaheadHighlightFilter; + + beforeEach(inject(function(_$log_, _$sce_) { + $log = _$log_; + $sce = _$sce_; + logSpy = spyOn($log, 'warn'); + })); + + beforeEach(inject(function(uibTypeaheadHighlightFilter) { + highlightFilter = uibTypeaheadHighlightFilter; })); - it('should higlight a match', function () { - expect(highlightFilter('before match after', 'match')).toEqual('before match after'); + it('should higlight a match', function() { + expect($sce.getTrustedHtml(highlightFilter('before match after', 'match'))).toEqual('before match after'); + }); + + it('should higlight a match with mixed case', function() { + expect($sce.getTrustedHtml(highlightFilter('before MaTch after', 'match'))).toEqual('before MaTch after'); + }); + + it('should higlight all matches', function() { + expect($sce.getTrustedHtml(highlightFilter('before MaTch after match', 'match'))).toEqual('before MaTch after match'); + }); + + it('should do nothing if no match', function() { + expect($sce.getTrustedHtml(highlightFilter('before match after', 'nomatch'))).toEqual('before match after'); }); - it('should higlight a match with mixed case', function () { - expect(highlightFilter('before MaTch after', 'match')).toEqual('before MaTch after'); + it('should do nothing if no or empty query', function() { + expect($sce.getTrustedHtml(highlightFilter('before match after', ''))).toEqual('before match after'); + expect($sce.getTrustedHtml(highlightFilter('before match after', null))).toEqual('before match after'); + expect($sce.getTrustedHtml(highlightFilter('before match after', undefined))).toEqual('before match after'); }); - it('should higlight all matches', function () { - expect(highlightFilter('before MaTch after match', 'match')).toEqual('before MaTch after match'); + it('issue 316 - should work correctly for regexp reserved words', function() { + expect($sce.getTrustedHtml(highlightFilter('before (match after', '(match'))).toEqual('before (match after'); }); - it('should do nothing if no match', function () { - expect(highlightFilter('before match after', 'nomatch')).toEqual('before match after'); + it('issue 1777 - should work correctly with numeric values', function() { + expect($sce.getTrustedHtml(highlightFilter(123, '2'))).toEqual('123'); }); - it('issue 316 - should work correctly for regexp reserved words', function () { - expect(highlightFilter('before (match after', '(match')).toEqual('before (match after'); + it('should show a warning when this component is being used unsafely', function() { + highlightFilter('before match after', 'match'); + expect(logSpy).toHaveBeenCalled(); }); }); diff --git a/src/typeahead/test/typeahead-parser.spec.js b/src/typeahead/test/typeahead-parser.spec.js index 46d51a0ac4..d201b73644 100644 --- a/src/typeahead/test/typeahead-parser.spec.js +++ b/src/typeahead/test/typeahead-parser.spec.js @@ -1,15 +1,14 @@ -describe('syntax parser', function () { - +describe('syntax parser', function() { var typeaheadParser, scope, filterFilter; beforeEach(module('ui.bootstrap.typeahead')); - beforeEach(inject(function (_$rootScope_, _filterFilter_, _typeaheadParser_) { - typeaheadParser = _typeaheadParser_; + beforeEach(inject(function(_$rootScope_, _filterFilter_, uibTypeaheadParser) { + typeaheadParser = uibTypeaheadParser; scope = _$rootScope_; filterFilter = _filterFilter_; })); - it('should parse the simplest array-based syntax', function () { + it('should parse the simplest array-based syntax', function() { scope.states = ['Alabama', 'California', 'Delaware']; var result = typeaheadParser.parse('state for state in states | filter:$viewValue'); @@ -22,8 +21,8 @@ describe('syntax parser', function () { expect(result.modelMapper(scope, locals)).toEqual('Alabama'); }); - it('should parse the simplest function-based syntax', function () { - scope.getStates = function ($viewValue) { + it('should parse the simplest function-based syntax', function() { + scope.getStates = function($viewValue) { return filterFilter(['Alabama', 'California', 'Delaware'], $viewValue); }; var result = typeaheadParser.parse('state for state in getStates($viewValue)'); @@ -38,13 +37,12 @@ describe('syntax parser', function () { }); it('should allow to specify custom model mapping that is used as a label as well', function () { - scope.states = [ {code:'AL', name:'Alabama'}, {code:'CA', name:'California'}, {code:'DE', name:'Delaware'} ]; - var result = typeaheadParser.parse("state.name for state in states | filter:$viewValue | orderBy:'name':true"); + var result = typeaheadParser.parse('state.name for state in states | filter:$viewValue | orderBy:"name":true'); var itemName = result.itemName; expect(itemName).toEqual('state'); @@ -59,14 +57,13 @@ describe('syntax parser', function () { expect(result.modelMapper(scope, locals)).toEqual('Alabama'); }); - it('should allow to specify custom view and model mappers', function () { - + it('should allow to specify custom view and model mappers', function() { scope.states = [ {code:'AL', name:'Alabama'}, {code:'CA', name:'California'}, {code:'DE', name:'Delaware'} ]; - var result = typeaheadParser.parse("state.code as state.name + ' ('+state.code+')' for state in states | filter:$viewValue | orderBy:'name':true"); + var result = typeaheadParser.parse('state.code as state.name + " ("+state.code+")" for state in states | filter:$viewValue | orderBy:"name":true'); var itemName = result.itemName; expect(result.source(scope, {$viewValue:'al'})).toEqual([ diff --git a/src/typeahead/test/typeahead-popup.spec.js b/src/typeahead/test/typeahead-popup.spec.js index 9ab1b18373..325d17a529 100644 --- a/src/typeahead/test/typeahead-popup.spec.js +++ b/src/typeahead/test/typeahead-popup.spec.js @@ -1,22 +1,20 @@ -describe('typeaheadPopup - result rendering', function () { - +describe('typeaheadPopup - result rendering', function() { var scope, $rootScope, $compile; beforeEach(module('ui.bootstrap.typeahead')); - beforeEach(module('template/typeahead/typeahead-popup.html')); - beforeEach(module('template/typeahead/typeahead-match.html')); - beforeEach(inject(function (_$rootScope_, _$compile_) { + beforeEach(module('uib/template/typeahead/typeahead-popup.html')); + beforeEach(module('uib/template/typeahead/typeahead-match.html')); + beforeEach(inject(function(_$rootScope_, _$compile_) { $rootScope = _$rootScope_; scope = $rootScope.$new(); $compile = _$compile_; })); - it('should render initial results', function () { - + it('should render initial results', function() { scope.matches = ['foo', 'bar', 'baz']; scope.active = 1; - var el = $compile("")(scope); + var el = $compile('')(scope); $rootScope.$digest(); var liElems = el.find('li'); @@ -26,12 +24,11 @@ describe('typeaheadPopup - result rendering', function () { expect(liElems.eq(2)).not.toHaveClass('active'); }); - it('should change active item on mouseenter', function () { - + it('should change active item on mouseenter', function() { scope.matches = ['foo', 'bar', 'baz']; scope.active = 1; - var el = $compile("")(scope); + var el = $compile('')(scope); $rootScope.$digest(); var liElems = el.find('li'); @@ -44,14 +41,13 @@ describe('typeaheadPopup - result rendering', function () { expect(liElems.eq(2)).toHaveClass('active'); }); - it('should select an item on mouse click', function () { - + it('should select an item on mouse click', function() { scope.matches = ['foo', 'bar', 'baz']; scope.active = 1; $rootScope.select = angular.noop; spyOn($rootScope, 'select'); - var el = $compile("")(scope); + var el = $compile('')(scope); $rootScope.$digest(); var liElems = el.find('li'); diff --git a/src/typeahead/test/typeahead.spec.js b/src/typeahead/test/typeahead.spec.js index b1cee67618..4423b8a66e 100644 --- a/src/typeahead/test/typeahead.spec.js +++ b/src/typeahead/test/typeahead.spec.js @@ -1,24 +1,31 @@ -describe('typeahead tests', function () { - - var $scope, $compile, $document; +describe('typeahead tests', function() { + var $scope, $compile, $document, $templateCache, $timeout, $window; var changeInputValueTo; beforeEach(module('ui.bootstrap.typeahead')); - beforeEach(module('template/typeahead/typeahead-popup.html')); - beforeEach(module('template/typeahead/typeahead-match.html')); + beforeEach(module('ngSanitize')); + beforeEach(module('uib/template/typeahead/typeahead-popup.html')); + beforeEach(module('uib/template/typeahead/typeahead-match.html')); beforeEach(module(function($compileProvider) { - $compileProvider.directive('formatter', function () { + $compileProvider.directive('formatter', function() { return { require: 'ngModel', link: function (scope, elm, attrs, ngModelCtrl) { - ngModelCtrl.$formatters.unshift(function (viewVal) { + ngModelCtrl.$formatters.unshift(function(viewVal) { return 'formatted' + viewVal; }); } }; }); + $compileProvider.directive('childDirective', function() { + return { + restrict: 'A', + require: '^parentDirective', + link: function(scope, element, attrs, ctrl) {} + }; + }); })); - beforeEach(inject(function (_$rootScope_, _$compile_, _$document_, $sniffer) { + beforeEach(inject(function(_$rootScope_, _$compile_, _$document_, _$templateCache_, _$timeout_, _$window_, $sniffer) { $scope = _$rootScope_; $scope.source = ['foo', 'bar', 'baz']; $scope.states = [ @@ -27,7 +34,10 @@ describe('typeahead tests', function () { ]; $compile = _$compile_; $document = _$document_; - changeInputValueTo = function (element, value) { + $templateCache = _$templateCache_; + $timeout = _$timeout_; + $window = _$window_; + changeInputValueTo = function(element, value) { var inputEl = findInput(element); inputEl.val(value); inputEl.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); @@ -36,84 +46,114 @@ describe('typeahead tests', function () { })); //utility functions - var prepareInputEl = function (inputTpl) { + var prepareInputEl = function(inputTpl) { var el = $compile(angular.element(inputTpl))($scope); $scope.$digest(); return el; }; - var findInput = function (element) { + var findInput = function(element) { return element.find('input'); }; - var findDropDown = function (element) { - return element.find('ul.typeahead'); + var findDropDown = function(element) { + return element.find('ul.dropdown-menu'); }; - var findMatches = function (element) { + var findMatches = function(element) { return findDropDown(element).find('li'); }; - var triggerKeyDown = function (element, keyCode) { + var triggerKeyDown = function(element, keyCode, options) { + options = options || {}; var inputEl = findInput(element); - var e = $.Event("keydown"); + var e = $.Event('keydown'); e.which = keyCode; + if (options.shiftKey) { + e.shiftKey = true; + } inputEl.trigger(e); }; //custom matchers beforeEach(function () { - this.addMatchers({ - toBeClosed: function () { - var typeaheadEl = findDropDown(this.actual); - this.message = function () { - return "Expected '" + angular.mock.dump(this.actual) + "' to be closed."; + jasmine.addMatchers({ + toBeClosed: function(util, customEqualityTesters) { + return { + compare: function(actual, expected) { + var typeaheadEl = findDropDown(actual); + + var result = { + pass: util.equals(typeaheadEl.hasClass('ng-hide'), true, customEqualityTesters) + }; + + if (result.pass) { + result.message = 'Expected "' + angular.mock.dump(typeaheadEl) + '" not to be closed.'; + } else { + result.message = 'Expected "' + angular.mock.dump(typeaheadEl) + '" to be closed.'; + } + + return result; + } }; - return typeaheadEl.css('display') === 'none' && findMatches(this.actual).length === 0; - - }, toBeOpenWithActive: function (noOfMatches, activeIdx) { - - var typeaheadEl = findDropDown(this.actual); - var liEls = findMatches(this.actual); - - this.message = function () { - return "Expected '" + angular.mock.dump(this.actual) + "' to be opened."; + }, + toBeOpenWithActive: function(util, customEqualityTesters) { + return { + compare: function(actual, noOfMatches, activeIdx) { + var typeaheadEl = findDropDown(actual); + var liEls = findMatches(actual); + + var result = { + pass: util.equals(typeaheadEl.length, 1, customEqualityTesters) && + util.equals(typeaheadEl.hasClass('ng-hide'), false, customEqualityTesters) && + util.equals(liEls.length, noOfMatches, customEqualityTesters) && + activeIdx === -1 ? !$(liEls).hasClass('active') : $(liEls[activeIdx]).hasClass('active') + }; + + if (result.pass) { + result.message = 'Expected "' + actual + '" not to be opened.'; + } else { + result.message = 'Expected "' + actual + '" to be opened.'; + } + + return result; + } }; - return typeaheadEl.css('display') === 'block' && liEls.length === noOfMatches && $(liEls[activeIdx]).hasClass('active'); } }); }); - //coarse grained, "integration" tests - describe('initial state and model changes', function () { + afterEach(function() { + findDropDown($document.find('body')).remove(); + }); - it('should be closed by default', function () { - var element = prepareInputEl(""); + //coarse grained, "integration" tests + describe('initial state and model changes', function() { + it('should be closed by default', function() { + var element = prepareInputEl(''); expect(element).toBeClosed(); }); - it('should correctly render initial state if the "as" keyword is used', function () { - + it('should correctly render initial state if the "as" keyword is used', function() { $scope.result = $scope.states[0]; - var element = prepareInputEl(""); + var element = prepareInputEl(''); var inputEl = findInput(element); expect(inputEl.val()).toEqual('Alaska'); }); - it('should default to bound model for initial rendering if there is not enough info to render label', function () { - + it('should default to bound model for initial rendering if there is not enough info to render label', function() { $scope.result = $scope.states[0].code; - var element = prepareInputEl(""); + var element = prepareInputEl(''); var inputEl = findInput(element); expect(inputEl.val()).toEqual('AL'); }); - it('should not get open on model change', function () { - var element = prepareInputEl(""); + it('should not get open on model change', function() { + var element = prepareInputEl(''); $scope.$apply(function () { $scope.result = 'foo'; }); @@ -121,63 +161,257 @@ describe('typeahead tests', function () { }); }); - describe('basic functionality', function () { + describe('basic functionality', function() { + it('should open and close typeahead based on matches', function() { + var element = prepareInputEl(''); + var inputEl = findInput(element); + var ownsId = inputEl.attr('aria-owns'); + + expect(inputEl.attr('aria-expanded')).toBe('false'); + expect(inputEl.attr('aria-activedescendant')).toBeUndefined(); + + changeInputValueTo(element, 'ba'); + expect(element).toBeOpenWithActive(2, 0); + expect(findDropDown(element).attr('id')).toBe(ownsId); + expect(inputEl.attr('aria-expanded')).toBe('true'); + var activeOptionId = ownsId + '-option-0'; + expect(inputEl.attr('aria-activedescendant')).toBe(activeOptionId); + expect(findDropDown(element).find('li.active').attr('id')).toBe(activeOptionId); + + changeInputValueTo(element, ''); + expect(element).toBeClosed(); + expect(inputEl.attr('aria-expanded')).toBe('false'); + expect(inputEl.attr('aria-activedescendant')).toBeUndefined(); + }); - it('should open and close typeahead based on matches', function () { - var element = prepareInputEl(""); + it('should allow expressions over multiple lines', function() { + var element = prepareInputEl(''); changeInputValueTo(element, 'ba'); expect(element).toBeOpenWithActive(2, 0); + + changeInputValueTo(element, ''); + expect(element).toBeClosed(); }); - it('should not open typeahead if input value smaller than a defined threshold', function () { - var element = prepareInputEl(""); + it('should not open typeahead if input value smaller than a defined threshold', function() { + var element = prepareInputEl(''); changeInputValueTo(element, 'b'); expect(element).toBeClosed(); }); - it('should support custom model selecting function', function () { - $scope.updaterFn = function (selectedItem) { + + it('should support changing min-length', function() { + $scope.typeAheadMinLength = 2; + var element = prepareInputEl(''); + + changeInputValueTo(element, 'b'); + + expect(element).toBeClosed(); + + $scope.typeAheadMinLength = 0; + $scope.$digest(); + changeInputValueTo(element, ''); + + expect(element).toBeOpenWithActive(3, 0); + + $scope.typeAheadMinLength = 2; + $scope.$digest(); + changeInputValueTo(element, 'b'); + + expect(element).toBeClosed(); + }); + + it('should support custom model selecting function', function() { + $scope.updaterFn = function(selectedItem) { return 'prefix' + selectedItem; }; - var element = prepareInputEl(""); + var element = prepareInputEl(''); changeInputValueTo(element, 'f'); triggerKeyDown(element, 13); expect($scope.result).toEqual('prefixfoo'); }); - it('should support custom label rendering function', function () { - $scope.formatterFn = function (sourceItem) { + it('should support custom label rendering function', function() { + $scope.formatterFn = function(sourceItem) { return 'prefix' + sourceItem; }; - var element = prepareInputEl(""); + var element = prepareInputEl(''); changeInputValueTo(element, 'fo'); var matchHighlight = findMatches(element).find('a').html(); expect(matchHighlight).toEqual('prefixfoo'); }); - it('should by default bind view value to model even if not part of matches', function () { - var element = prepareInputEl(""); + it('should by default bind view value to model even if not part of matches', function() { + var element = prepareInputEl(''); changeInputValueTo(element, 'not in matches'); expect($scope.result).toEqual('not in matches'); }); - it('should support the editable property to limit model bindings to matches only', function () { - var element = prepareInputEl(""); + it('should support the editable property to limit model bindings to matches only', function() { + var element = prepareInputEl(''); + changeInputValueTo(element, 'not in matches'); + expect($scope.result).toEqual(undefined); + }); + + it('should set validation errors for non-editable inputs', function() { + var element = prepareInputEl( + '' + + '' + + ''); + + changeInputValueTo(element, 'not in matches'); + expect($scope.result).toEqual(undefined); + expect($scope.form.input.$error.editable).toBeTruthy(); + + changeInputValueTo(element, 'foo'); + triggerKeyDown(element, 13); + expect($scope.result).toEqual('foo'); + expect($scope.form.input.$error.editable).toBeFalsy(); + }); + + it('should not set editable validation error for empty input', function() { + var element = prepareInputEl( + '' + + '' + + ''); + + changeInputValueTo(element, 'not in matches'); + expect($scope.result).toEqual(undefined); + expect($scope.form.input.$error.editable).toBeTruthy(); + changeInputValueTo(element, ''); + expect($scope.result).toEqual(null); + expect($scope.form.input.$error.editable).toBeFalsy(); + }); + + it('should clear view value after blur for typeahead-editable="false"', function () { + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'not in matches'); + expect($scope.result).toEqual(undefined); + expect(inputEl.val()).toEqual('not in matches'); + inputEl.blur(); // input loses focus + expect($scope.result).toEqual(undefined); + expect(inputEl.val()).toEqual(''); + }); + + it('should clear errors after blur for typeahead-editable="false"', function () { + var element = prepareInputEl( + '' + + '' + + ''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'not in matches'); + expect($scope.result).toEqual(undefined); + expect(inputEl.val()).toEqual('not in matches'); + inputEl.blur(); + + expect($scope.form.input.$error.editable).toBeFalsy(); + expect($scope.form.input.$error.parse).toBeFalsy(); + }); + + // fix for #6032 + it('should clear errors and refresh scope after blur for typeahead-editable="false"', function () { + var element = prepareInputEl( + '' + + '' + + ''); + var inputEl = findInput(element); + + // first try + changeInputValueTo(element, 'not in matches'); + expect($scope.result).toEqual(undefined); + expect(inputEl.val()).toEqual('not in matches'); + expect(element.find('form')).toHaveClass('invalid'); + inputEl.blur(); + + expect(inputEl.val()).toEqual(''); // <-- input is reset + expect($scope.form.input.$error.editable).toBeFalsy(); + expect($scope.form.input.$error.parse).toBeFalsy(); + expect(element.find('form')).not.toHaveClass('invalid'); // <-- form has no error (it always works for some reason) + + // second try + changeInputValueTo(element, 'not in matches'); + expect($scope.result).toEqual(undefined); + expect(inputEl.val()).toEqual('not in matches'); + expect(element.find('form')).toHaveClass('invalid'); + inputEl.blur(); + + expect(inputEl.val()).toEqual(''); // <-- input is reset + expect($scope.form.input.$error.editable).toBeFalsy(); + expect($scope.form.input.$error.parse).toBeFalsy(); + expect(element.find('form')).not.toHaveClass('invalid'); // <-- form has no error (it didn't work prior to #6032 fix) + }); + + it('should go through other validators after blur for typeahead-editable="false"', function () { + var element = prepareInputEl( + '' + + '' + + ''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'not in matches'); + expect($scope.result).toEqual(undefined); + expect(inputEl.val()).toEqual('not in matches'); + inputEl.blur(); // input loses focus + expect($scope.result).toEqual(undefined); + expect($scope.form.input.$error.required).toBeTruthy(); + }); + + it('should clear view value when no value selected for typeahead-editable="false" typeahead-select-on-blur="false"', function () { + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'b'); + expect($scope.result).toEqual(undefined); + expect(inputEl.val()).toEqual('b'); + inputEl.blur(); // input loses focus + expect($scope.result).toEqual(undefined); + expect(inputEl.val()).toEqual(''); + }); + + it('should not clear view value when there is match but no value selected for typeahead-editable="false" typeahead-select-on-blur="true"', function () { + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'b'); + expect($scope.result).toEqual(undefined); + expect(inputEl.val()).toEqual('b'); + inputEl.blur(); // input loses focus + expect($scope.result).toEqual('bar'); + expect(inputEl.val()).toEqual('bar'); + }); + + it('should support changing the editable property to limit model bindings to matches only', function() { + $scope.isEditable = true; + var element = prepareInputEl(''); + $scope.isEditable = false; + $scope.$digest(); changeInputValueTo(element, 'not in matches'); expect($scope.result).toEqual(undefined); }); - it('should bind loading indicator expression', inject(function ($timeout) { + it('should support changing the editable property to bind view value to model even if not part of matches', function() { + $scope.isEditable = false; + var element = prepareInputEl(''); + $scope.isEditable = true; + $scope.$digest(); + changeInputValueTo(element, 'not in matches'); + expect($scope.result).toEqual('not in matches'); + }); + it('should bind loading indicator expression', inject(function($timeout) { $scope.isLoading = false; - $scope.loadMatches = function (viewValue) { - return $timeout(function () { + $scope.loadMatches = function(viewValue) { + return $timeout(function() { return []; }, 1000); }; - var element = prepareInputEl(""); + var element = prepareInputEl(''); changeInputValueTo(element, 'foo'); expect($scope.isLoading).toBeTruthy(); @@ -185,9 +419,8 @@ describe('typeahead tests', function () { expect($scope.isLoading).toBeFalsy(); })); - it('should support timeout before trying to match $viewValue', inject(function ($timeout) { - - var element = prepareInputEl(""); + it('should support timeout before trying to match $viewValue', inject(function($timeout) { + var element = prepareInputEl(''); changeInputValueTo(element, 'foo'); expect(element).toBeClosed(); @@ -195,13 +428,13 @@ describe('typeahead tests', function () { expect(element).toBeOpenWithActive(1, 0); })); - it('should cancel old timeouts when something is typed within waitTime', inject(function ($timeout) { + it('should cancel old timeouts when something is typed within waitTime', inject(function($timeout) { var values = []; $scope.loadMatches = function(viewValue) { values.push(viewValue); return $scope.source; }; - var element = prepareInputEl(""); + var element = prepareInputEl(''); changeInputValueTo(element, 'first'); changeInputValueTo(element, 'second'); @@ -210,15 +443,15 @@ describe('typeahead tests', function () { expect(values).not.toContain('first'); })); - it('should allow timeouts when something is typed after waitTime has passed', inject(function ($timeout) { + it('should allow timeouts when something is typed after waitTime has passed', inject(function($timeout) { var values = []; $scope.loadMatches = function(viewValue) { values.push(viewValue); return $scope.source; }; - var element = prepareInputEl(""); - + var element = prepareInputEl(''); + changeInputValueTo(element, 'first'); $timeout.flush(); @@ -230,24 +463,61 @@ describe('typeahead tests', function () { expect(values).toContain('second'); })); - it('should support custom templates for matched items', inject(function ($templateCache) { + it('should support custom popup templates', function() { + $templateCache.put('custom.html', 'foo'); + + var element = prepareInputEl(''); + + changeInputValueTo(element, 'Al'); + + expect(element.find('.custom').text()).toBe('foo'); + }); + it('should support custom templates for matched items', function() { $templateCache.put('custom.html', '{{ index }} {{ match.label }}'); - var element = prepareInputEl(""); - var inputEl = findInput(element); + var element = prepareInputEl(''); changeInputValueTo(element, 'Al'); expect(findMatches(element).eq(0).find('p').text()).toEqual('0 Alaska'); - })); - }); + }); + + it('should support directives which require controllers in custom templates for matched items', function() { + $templateCache.put('custom.html', '{{ index }} {{ match.label }}'); + + var element = prepareInputEl(''); + + element.data('$parentDirectiveController', {}); - describe('selecting a match', function () { + changeInputValueTo(element, 'Al'); + + expect(findMatches(element).eq(0).find('p').text()).toEqual('0 Alaska'); + }); + + it('should throw error on invalid expression', function() { + var prepareInvalidDir = function() { + prepareInputEl(''); + }; + expect(prepareInvalidDir).toThrow(); + }); + + it('should remove the id attribute from the original DOM element', function() { + var element = prepareInputEl(''); + var inputEl = findInput(element); - it('should select a match on enter', function () { + expect(inputEl.size()).toBe(2); + expect(inputEl.eq(0).attr('id')).toBe(undefined); + expect(inputEl.eq(1).attr('id')).toBe('typeahead-element'); + }); + }); - var element = prepareInputEl(""); + describe('shouldSelect', function() { + it('should select a match when function returns true', function() { + $scope.shouldSelectFn = function() { + return true; + }; + var element = prepareInputEl(''); var inputEl = findInput(element); changeInputValueTo(element, 'b'); @@ -257,10 +527,48 @@ describe('typeahead tests', function () { expect(inputEl.val()).toEqual('bar'); expect(element).toBeClosed(); }); + it('should not select a match when function returns false', function() { + $scope.shouldSelectFn = function() { + return false; + }; + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'b'); + triggerKeyDown(element, 13); + + // no change + expect($scope.result).toEqual('b'); + expect(inputEl.val()).toEqual('b'); + }); + it('should pass key event into select trigger function', function() { + $scope.shouldSelectFn = jasmine.createSpy('shouldSelectFn');//.and.returnValue(true); + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'b'); + triggerKeyDown(element, 13); + + expect($scope.shouldSelectFn.calls.count()).toEqual(1); + expect($scope.shouldSelectFn.calls.argsFor(0)[0].which).toEqual(13); + }); + }); + + describe('selecting a match', function() { + it('should select a match on enter', function() { + var element = prepareInputEl(''); + var inputEl = findInput(element); - it('should select a match on tab', function () { + changeInputValueTo(element, 'b'); + triggerKeyDown(element, 13); + + expect($scope.result).toEqual('bar'); + expect(inputEl.val()).toEqual('bar'); + expect(element).toBeClosed(); + }); - var element = prepareInputEl(""); + it('should select a match on tab', function() { + var element = prepareInputEl(''); var inputEl = findInput(element); changeInputValueTo(element, 'b'); @@ -271,9 +579,32 @@ describe('typeahead tests', function () { expect(element).toBeClosed(); }); - it('should select match on click', function () { + it('should not select any match on blur without \'select-on-blur=true\' option', function() { + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'b'); + inputEl.blur(); // input loses focus + + // no change + expect($scope.result).toEqual('b'); + expect(inputEl.val()).toEqual('b'); + }); + + it('should select a match on blur with \'select-on-blur=true\' option', function() { + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'b'); + inputEl.blur(); // input loses focus + + // first element should be selected + expect($scope.result).toEqual('bar'); + expect(inputEl.val()).toEqual('bar'); + }); - var element = prepareInputEl(""); + it('should select match on click', function() { + var element = prepareInputEl(''); var inputEl = findInput(element); changeInputValueTo(element, 'b'); @@ -287,15 +618,14 @@ describe('typeahead tests', function () { expect(element).toBeClosed(); }); - it('should invoke select callback on select', function () { - - $scope.onSelect = function ($item, $model, $label) { + it('should invoke select callback on select', function() { + $scope.onSelect = function($item, $model, $label, $event) { $scope.$item = $item; $scope.$model = $model; $scope.$label = $label; + $scope.$event = $event; }; - var element = prepareInputEl(""); - var inputEl = findInput(element); + var element = prepareInputEl(''); changeInputValueTo(element, 'Alas'); triggerKeyDown(element, 13); @@ -304,11 +634,11 @@ describe('typeahead tests', function () { expect($scope.$item).toEqual($scope.states[0]); expect($scope.$model).toEqual('AL'); expect($scope.$label).toEqual('Alaska'); + expect($scope.$event.type).toEqual("keydown"); }); - it('should correctly update inputs value on mapping where label is not derived from the model', function () { - - var element = prepareInputEl(""); + it('should correctly update inputs value on mapping where label is not derived from the model', function() { + var element = prepareInputEl(''); var inputEl = findInput(element); changeInputValueTo(element, 'Alas'); @@ -317,62 +647,951 @@ describe('typeahead tests', function () { expect($scope.result).toEqual('AL'); expect(inputEl.val()).toEqual('AL'); }); - }); - describe('non-regressions tests', function () { + it('should bind no results indicator as true when no matches returned', inject(function($timeout) { + $scope.isNoResults = false; + $scope.loadMatches = function(viewValue) { + return $timeout(function() { + return []; + }, 1000); + }; + + var element = prepareInputEl(''); + changeInputValueTo(element, 'foo'); + + expect($scope.isNoResults).toBeFalsy(); + $timeout.flush(); + expect($scope.isNoResults).toBeTruthy(); + })); + + it('should bind no results indicator as false when matches are returned', inject(function($timeout) { + $scope.isNoResults = false; + $scope.loadMatches = function(viewValue) { + return $timeout(function() { + return [viewValue]; + }, 1000); + }; + + var element = prepareInputEl(''); + changeInputValueTo(element, 'foo'); + + expect($scope.isNoResults).toBeFalsy(); + $timeout.flush(); + expect($scope.isNoResults).toBeFalsy(); + })); - it('issue 231 - closes matches popup on click outside typeahead', function () { - var element = prepareInputEl(""); + it('should not focus the input if `typeahead-focus-on-select` is false', function() { + var element = prepareInputEl(''); + $document.find('body').append(element); var inputEl = findInput(element); changeInputValueTo(element, 'b'); + var match = $(findMatches(element)[1]).find('a')[0]; - $document.find('body').click(); + $(match).click(); $scope.$digest(); + $timeout.flush(); + + expect(document.activeElement).not.toBe(inputEl[0]); + expect($scope.result).toEqual('baz'); + }); + }); + + describe('select on exact match', function() { + it('should select on an exact match when set', function() { + $scope.onSelect = jasmine.createSpy('onSelect'); + var element = prepareInputEl(''); + var inputEl = findInput(element); + changeInputValueTo(element, 'bar'); + + expect($scope.result).toEqual('bar'); + expect(inputEl.val()).toEqual('bar'); expect(element).toBeClosed(); + expect($scope.onSelect).toHaveBeenCalled(); }); - it('issue 591 - initial formatting for un-selected match and complex label expression', function () { + it('should not select on an exact match by default', function() { + $scope.onSelect = jasmine.createSpy('onSelect'); + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'bar'); - var inputEl = findInput(prepareInputEl("")); - expect(inputEl.val()).toEqual(''); + expect($scope.result).toBeUndefined(); + expect(inputEl.val()).toEqual('bar'); + expect($scope.onSelect.calls.any()).toBe(false); }); - it('issue 786 - name of internal model should not conflict with scope model name', function () { - $scope.state = $scope.states[0]; - var element = prepareInputEl(""); + it('should not be case sensitive when select on an exact match', function() { + $scope.onSelect = jasmine.createSpy('onSelect'); + var element = prepareInputEl(''); var inputEl = findInput(element); - expect(inputEl.val()).toEqual('Alaska'); + changeInputValueTo(element, 'BaR'); + + expect($scope.result).toEqual('bar'); + expect(inputEl.val()).toEqual('bar'); + expect(element).toBeClosed(); + expect($scope.onSelect).toHaveBeenCalled(); + }); + + it('should not auto select when not a match with one potential result left', function() { + $scope.onSelect = jasmine.createSpy('onSelect'); + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'fo'); + + expect($scope.result).toBeUndefined(); + expect(inputEl.val()).toEqual('fo'); + expect($scope.onSelect.calls.any()).toBe(false); }); }); - describe('input formatting', function () { + describe('is-open indicator', function () { + var element; - it('should co-operate with existing formatters', function () { + beforeEach(function () { + element = prepareInputEl(''); + }); - $scope.result = $scope.states[0]; + it('should bind is-open indicator as true when matches are returned', function () { + expect($scope.isOpen).toBeFalsy(); + changeInputValueTo(element, 'b'); + expect($scope.isOpen).toBeTruthy(); + }); - var element = prepareInputEl(""), - inputEl = findInput(element); + it('should bind is-open indicator as false when no matches returned', function () { + expect($scope.isOpen).toBeFalsy(); + changeInputValueTo(element, 'b'); + expect($scope.isOpen).toBeTruthy(); + changeInputValueTo(element, 'not match'); + expect($scope.isOpen).toBeFalsy(); + }); - expect(inputEl.val()).toEqual('formatted' + $scope.result.name); + it('should bind is-open indicator as false when a match is clicked', function () { + expect($scope.isOpen).toBeFalsy(); + changeInputValueTo(element, 'b'); + expect($scope.isOpen).toBeTruthy(); + var match = findMatches(element).find('a').eq(0); + + match.click(); + $scope.$digest(); + expect($scope.isOpen).toBeFalsy(); + }); + it('should bind is-open indicator as false when click outside', function () { + expect($scope.isOpen).toBeFalsy(); + changeInputValueTo(element, 'b'); + expect($scope.isOpen).toBeTruthy(); + $document.find('body').click(); + $scope.$digest(); + expect($scope.isOpen).toBeFalsy(); + }); + + it('should bind is-open indicator as false on enter', function () { + expect($scope.isOpen).toBeFalsy(); + changeInputValueTo(element, 'b'); + expect($scope.isOpen).toBeTruthy(); + triggerKeyDown(element, 13); + expect($scope.isOpen).toBeFalsy(); + }); + + it('should bind is-open indicator as false on tab', function () { + expect($scope.isOpen).toBeFalsy(); + changeInputValueTo(element, 'b'); + expect($scope.isOpen).toBeTruthy(); + triggerKeyDown(element, 9); + expect($scope.isOpen).toBeFalsy(); + }); + + it('should bind is-open indicator as false on escape key', function () { + expect($scope.isOpen).toBeFalsy(); + changeInputValueTo(element, 'b'); + expect($scope.isOpen).toBeTruthy(); + triggerKeyDown(element, 27); + expect($scope.isOpen).toBeFalsy(); + }); + + it('should bind is-open indicator as false input value smaller than a defined threshold', function () { + var element = prepareInputEl(''); + expect($scope.isToggled).toBeFalsy(); + changeInputValueTo(element, 'b'); + expect($scope.isToggled).toBeFalsy(); + }); + }); + + describe('pop-up interaction', function() { + var element; + + beforeEach(function() { + element = prepareInputEl(''); + }); + + it('should activate prev/next matches on up/down keys', function() { + changeInputValueTo(element, 'b'); + var parentNode = element.find('ul').eq(0)[0]; + var liIndex; + + liIndex = 0; + expect(element).toBeOpenWithActive(2, liIndex); + expect(parentNode.scrollTop).toEqual(element.find('li').eq(liIndex)[0].offsetTop); + + // Down arrow key + triggerKeyDown(element, 40); + liIndex = 1; + expect(element).toBeOpenWithActive(2, liIndex); + expect(parentNode.scrollTop).toEqual(element.find('li').eq(liIndex)[0].offsetTop); + + // Down arrow key goes back to first element + triggerKeyDown(element, 40); + liIndex = 0; + expect(element).toBeOpenWithActive(2, liIndex); + expect(parentNode.scrollTop).toEqual(element.find('li').eq(liIndex)[0].offsetTop); + + // Up arrow key goes back to last element + triggerKeyDown(element, 38); + liIndex = 1; + expect(element).toBeOpenWithActive(2, liIndex); + expect(parentNode.scrollTop).toEqual(element.find('li').eq(liIndex)[0].offsetTop); + + // Up arrow key goes back to first element + triggerKeyDown(element, 38); + liIndex = 0; + expect(parentNode.scrollTop).toEqual(element.find('li').eq(liIndex)[0].offsetTop); + expect(element).toBeOpenWithActive(2, liIndex); }); - it('should support a custom input formatting function', function () { + it('should close popup on escape key', function() { + changeInputValueTo(element, 'b'); + expect(element).toBeOpenWithActive(2, 0); - $scope.result = $scope.states[0]; - $scope.formatInput = function($model) { - return $model.code; + // Escape key + triggerKeyDown(element, 27); + expect(element).toBeClosed(); + }); + + it('should highlight match on mouseenter', function() { + changeInputValueTo(element, 'b'); + expect(element).toBeOpenWithActive(2, 0); + + findMatches(element).eq(1).trigger('mouseenter'); + expect(element).toBeOpenWithActive(2, 1); + }); + }); + + describe('promises', function() { + var element, deferred; + + beforeEach(inject(function($q) { + deferred = $q.defer(); + $scope.source = function() { + return deferred.promise; }; + element = prepareInputEl(''); + })); - var element = prepareInputEl(""), - inputEl = findInput(element); + it('should display matches from promise', function() { + changeInputValueTo(element, 'c'); + expect(element).toBeClosed(); - expect(inputEl.val()).toEqual('AL'); - expect($scope.result).toEqual($scope.states[0]); + deferred.resolve(['good', 'stuff']); + $scope.$digest(); + expect(element).toBeOpenWithActive(2, 0); + }); + + it('should not display anything when promise is rejected', function() { + changeInputValueTo(element, 'c'); + expect(element).toBeClosed(); + + deferred.reject('fail'); + $scope.$digest(); + expect(element).toBeClosed(); + }); + + it('PR #3178, resolves #2999 - should not return property "length" of undefined for undefined matches', function() { + changeInputValueTo(element, 'c'); + expect(element).toBeClosed(); + + deferred.resolve(); + $scope.$digest(); + expect(element).toBeClosed(); }); }); -}); \ No newline at end of file + describe('non-regressions tests', function() { + + it('issue 231 - closes matches popup on click outside typeahead', function() { + var element = prepareInputEl(''); + + changeInputValueTo(element, 'b'); + + $document.find('body').click(); + $scope.$digest(); + + expect(element).toBeClosed(); + }); + + it('issue 591 - initial formatting for un-selected match and complex label expression', function() { + var inputEl = findInput(prepareInputEl('')); + expect(inputEl.val()).toEqual(''); + }); + + it('issue 786 - name of internal model should not conflict with scope model name', function() { + $scope.state = $scope.states[0]; + var element = prepareInputEl(''); + var inputEl = findInput(element); + + expect(inputEl.val()).toEqual('Alaska'); + }); + + it('issue 863 - it should work correctly with input type="email"', function() { + $scope.emails = ['foo@host.com', 'bar@host.com']; + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'bar'); + expect(element).toBeOpenWithActive(1, 0); + + triggerKeyDown(element, 13); + + expect($scope.email).toEqual('bar@host.com'); + expect(inputEl.val()).toEqual('bar@host.com'); + }); + + it('issue 964 - should not show popup with matches if an element is not focused', function() { + $scope.items = function(viewValue) { + return $timeout(function() { + return [viewValue]; + }); + }; + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'match'); + $scope.$digest(); + + inputEl.blur(); + $timeout.flush(); + + expect(element).toBeClosed(); + }); + + it('should properly update loading callback if an element is not focused', function() { + $scope.items = function(viewValue) { + return $timeout(function(){ + return [viewValue]; + }); + }; + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'match'); + $scope.$digest(); + + inputEl.blur(); + $timeout.flush(); + + expect($scope.isLoading).toBeFalsy(); + }); + + it('issue 1140 - should properly update loading callback when deleting characters', function() { + $scope.items = function(viewValue) { + return $timeout(function() { + return [viewValue]; + }); + }; + var element = prepareInputEl(''); + + changeInputValueTo(element, 'match'); + $scope.$digest(); + + expect($scope.isLoading).toBeTruthy(); + + changeInputValueTo(element, 'm'); + $timeout.flush(); + $scope.$digest(); + + expect($scope.isLoading).toBeFalsy(); + }); + + it('should cancel old timeout when deleting characters', inject(function($timeout) { + var values = []; + $scope.loadMatches = function(viewValue) { + values.push(viewValue); + return $scope.source; + }; + var element = prepareInputEl(''); + changeInputValueTo(element, 'match'); + changeInputValueTo(element, 'm'); + + $timeout.flush(); + + expect(values).not.toContain('match'); + })); + + describe('', function() { + // Dummy describe to be able to create an after hook for this tests + var element; + + it('does not close matches popup on click in input', function() { + element = prepareInputEl(''); + var inputEl = findInput(element); + + // Note that this bug can only be found when element is in the document + $document.find('body').append(element); + + changeInputValueTo(element, 'b'); + + inputEl.click(); + $scope.$digest(); + + expect(element).toBeOpenWithActive(2, 0); + }); + + it('issue #1773 - should not trigger an error when used with ng-focus', function() { + element = prepareInputEl(''); + var inputEl = findInput(element); + + // Note that this bug can only be found when element is in the document + $document.find('body').append(element); + + changeInputValueTo(element, 'b'); + var match = $(findMatches(element)[1]).find('a')[0]; + + $(match).click(); + $scope.$digest(); + }); + + afterEach(function() { + element.remove(); + }); + }); + + it('issue #1238 - allow names like "query" to be used inside "in" expressions ', function() { + $scope.query = function() { + return ['foo', 'bar']; + }; + + var element = prepareInputEl(''); + changeInputValueTo(element, 'bar'); + + expect(element).toBeOpenWithActive(2, 0); + }); + + it('issue #3318 - should set model validity to true when set manually', function() { + var element = prepareInputEl( + '' + + '' + + ''); + + changeInputValueTo(element, 'not in matches'); + $scope.$apply(function() { + $scope.result = 'manually set'; + }); + + expect($scope.result).toEqual('manually set'); + expect($scope.form.input.$valid).toBeTruthy(); + }); + + it('issue #3166 - should set \'parse\' key as valid when selecting a perfect match and not editable', function() { + var element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'Alaska'); + triggerKeyDown(element, 13); + + expect($scope.test.typeahead.$error.parse).toBeUndefined(); + }); + }); + + describe('ng-model-options', function() { + it('should support getterSetter', function() { + function resultSetter(state) { + return state; + } + $scope.result = resultSetter; + var element = prepareInputEl(''); + + changeInputValueTo(element, 'Alaska'); + triggerKeyDown(element, 13); + + expect($scope.result).toBe(resultSetter); + }); + + describe('debounce as a number', function() { + it('should work with selecting via keyboard', function() { + element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'Alaska'); + triggerKeyDown(element, 13); + + expect($scope.result).not.toBe('Alaska'); + + $timeout.flush(400); + + expect($scope.result).toBe('Alaska'); + }); + + it('should work with select on exact', function() { + element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'Alaska'); + + expect($scope.result).not.toBe('Alaska'); + + $timeout.flush(400); + + expect($scope.result).toBe('Alaska'); + }); + + it('should work with selecting a match via click', function() { + element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'Alaska'); + var match = $(findMatches(element)[0]).find('a')[0]; + + $(match).click(); + $scope.$digest(); + + expect($scope.result).not.toBe('Alaska'); + + $timeout.flush(400); + + expect($scope.result).toBe('Alaska'); + }); + }); + + describe('debounce as an object', function() { + it('should work with selecting via keyboard', function() { + element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'Alaska'); + triggerKeyDown(element, 13); + + expect($scope.result).not.toBe('Alaska'); + + $timeout.flush(400); + + expect($scope.result).toBe('Alaska'); + }); + + it('should work with select on exact', function() { + element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'Alaska'); + + expect($scope.result).not.toBe('Alaska'); + + $timeout.flush(400); + + expect($scope.result).toBe('Alaska'); + }); + + it('should work with selecting a match via click', function() { + element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'Alaska'); + var match = $(findMatches(element)[0]).find('a')[0]; + + $(match).click(); + $scope.$digest(); + + expect($scope.result).not.toBe('Alaska'); + + $timeout.flush(400); + + expect($scope.result).toBe('Alaska'); + }); + + it('should work when blurring and select on blur', function() { + element = prepareInputEl(''); + var inputEl = findInput(element); + + changeInputValueTo(element, 'Alaska'); + element.blur(); + $scope.$digest(); + + expect($scope.result).not.toBe('Alaska'); + + $timeout.flush(500); + + expect($scope.result).toBe('Alaska'); + }); + }); + }); + + describe('input formatting', function() { + it('should co-operate with existing formatters', function() { + $scope.result = $scope.states[0]; + + var element = prepareInputEl(''), + inputEl = findInput(element); + + expect(inputEl.val()).toEqual('formatted' + $scope.result.name); + }); + + it('should support a custom input formatting function', function() { + $scope.result = $scope.states[0]; + $scope.formatInput = function($model) { + return $model.code; + }; + + var element = prepareInputEl(''), + inputEl = findInput(element); + + expect(inputEl.val()).toEqual('AL'); + expect($scope.result).toEqual($scope.states[0]); + }); + }); + + describe('input hint', function() { + var element; + + beforeEach(function() { + element = prepareInputEl(''); + }); + + it('should show hint when input matches first match', function() { + var hintEl = findInput(element); + + expect(hintEl.val()).toEqual(''); + changeInputValueTo(element, 'Alas'); + expect(hintEl.val()).toEqual('Alaska'); + }); + + it('should not show hint when input does not match first match', function() { + var hintEl = findInput(element); + + expect(hintEl.val()).toEqual(''); + changeInputValueTo(element, 'las'); + expect(hintEl.val()).toEqual(''); + }); + + it('should reset hint when a match is clicked', function() { + var hintEl = findInput(element); + + expect(hintEl.val()).toEqual(''); + changeInputValueTo(element, 'Alas'); + expect(hintEl.val()).toEqual('Alaska'); + + var match = findMatches(element).find('a').eq(0); + match.click(); + $scope.$digest(); + expect(hintEl.val()).toEqual(''); + }); + + it('should reset hint when click outside', function() { + var hintEl = findInput(element); + + expect(hintEl.val()).toEqual(''); + changeInputValueTo(element, 'Alas'); + expect(hintEl.val()).toEqual('Alaska'); + + $document.find('body').click(); + $scope.$digest(); + expect(hintEl.val()).toEqual(''); + }); + + it('should reset hint on enter', function() { + var hintEl = findInput(element); + + expect(hintEl.val()).toEqual(''); + changeInputValueTo(element, 'Alas'); + expect(hintEl.val()).toEqual('Alaska'); + triggerKeyDown(element, 13); + expect(hintEl.val()).toEqual(''); + }); + + it('should reset hint on tab', function() { + var hintEl = findInput(element); + + expect(hintEl.val()).toEqual(''); + changeInputValueTo(element, 'Alas'); + expect(hintEl.val()).toEqual('Alaska'); + triggerKeyDown(element, 9); + expect(hintEl.val()).toEqual(''); + }); + + it('should reset hint on escape key', function() { + var hintEl = findInput(element); + + expect(hintEl.val()).toEqual(''); + changeInputValueTo(element, 'Alas'); + expect(hintEl.val()).toEqual('Alaska'); + triggerKeyDown(element, 27); + expect(hintEl.val()).toEqual(''); + }); + + it("should set tab index on hint input element", function(){ + var hintEl = findInput(element); + expect(hintEl.attr('tabindex')).toEqual('-1'); + }); + }); + + describe('append to', function() { + it('append typeahead results to element', function() { + $document.find('body').append(''); + $scope.myElement = $document.find('#myElement'); + var element = prepareInputEl(''); + changeInputValueTo(element, 'al'); + expect($document.find('#myElement')).toBeOpenWithActive(2, 0); + $document.find('#myElement').remove(); + }); + }); + + describe('append to body', function() { + afterEach(function() { + angular.element($window).off('resize'); + $document.find('body').off('scroll'); + }); + + it('append typeahead results to body', function() { + var element = prepareInputEl(''); + changeInputValueTo(element, 'ba'); + expect($document.find('body')).toBeOpenWithActive(2, 0); + }); + + it('should not append to body when value of the attribute is false', function() { + var element = prepareInputEl(''); + changeInputValueTo(element, 'ba'); + expect(findDropDown($document.find('body')).length).toEqual(0); + }); + + it('should have right position after scroll', function() { + var element = prepareInputEl(''); + var dropdown = findDropDown($document.find('body')); + var body = angular.element(document.body); + + // Set body height to allow scrolling + body.css({height:'10000px'}); + + // Scroll top + window.scroll(0, 1000); + + // Set input value to show dropdown + changeInputValueTo(element, 'ba'); + + // Init position of dropdown must be 1000px + expect(dropdown.css('top') ).toEqual('1000px'); + + // After scroll, must have new position + window.scroll(0, 500); + body.triggerHandler('scroll'); + $timeout.flush(); + expect(dropdown.css('top')).toEqual('500px'); + }); + }); + + describe('focus first', function() { + it('should focus the first element by default', function() { + var element = prepareInputEl(''); + changeInputValueTo(element, 'b'); + expect(element).toBeOpenWithActive(2, 0); + + // Down arrow key + triggerKeyDown(element, 40); + expect(element).toBeOpenWithActive(2, 1); + + // Down arrow key goes back to first element + triggerKeyDown(element, 40); + expect(element).toBeOpenWithActive(2, 0); + + // Up arrow key goes back to last element + triggerKeyDown(element, 38); + expect(element).toBeOpenWithActive(2, 1); + + // Up arrow key goes back to first element + triggerKeyDown(element, 38); + expect(element).toBeOpenWithActive(2, 0); + }); + + it('should not focus the first element until keys are pressed', function() { + var element = prepareInputEl(''); + changeInputValueTo(element, 'b'); + expect(element).toBeOpenWithActive(2, -1); + + // Down arrow key goes to first element + triggerKeyDown(element, 40); + expect(element).toBeOpenWithActive(2, 0); + + // Down arrow key goes to second element + triggerKeyDown(element, 40); + expect(element).toBeOpenWithActive(2, 1); + + // Down arrow key goes back to first element + triggerKeyDown(element, 40); + expect(element).toBeOpenWithActive(2, 0); + + // Up arrow key goes back to last element + triggerKeyDown(element, 38); + expect(element).toBeOpenWithActive(2, 1); + + // Up arrow key goes back to first element + triggerKeyDown(element, 38); + expect(element).toBeOpenWithActive(2, 0); + + // New input goes back to no focus + changeInputValueTo(element, 'a'); + changeInputValueTo(element, 'b'); + expect(element).toBeOpenWithActive(2, -1); + + // Up arrow key goes to last element + triggerKeyDown(element, 38); + expect(element).toBeOpenWithActive(2, 1); + }); + }); + + it('should not capture enter or tab when an item is not focused', function() { + $scope.select_count = 0; + $scope.onSelect = function($item, $model, $label) { + $scope.select_count = $scope.select_count + 1; + }; + var element = prepareInputEl(''); + changeInputValueTo(element, 'b'); + + // enter key should not be captured when nothing is focused + triggerKeyDown(element, 13); + expect($scope.keyDownEvent.isDefaultPrevented()).toBeFalsy(); + expect($scope.select_count).toEqual(0); + + // tab key should close the dropdown when nothing is focused + triggerKeyDown(element, 9); + expect($scope.keyDownEvent.isDefaultPrevented()).toBeFalsy(); + expect($scope.select_count).toEqual(0); + expect(element).toBeClosed(); + }); + + it("should not capture tab when shift key is pressed", function(){ + $scope.select_count = 0; + $scope.onSelect = function($item, $model, $label) { + $scope.select_count = $scope.select_count + 1; + }; + var element = prepareInputEl(''); + changeInputValueTo(element, 'b'); + + // down key should be captured and focus first element + triggerKeyDown(element, 40); + + triggerKeyDown(element, 9, {shiftKey: true}); + expect($scope.keyDownEvent.isDefaultPrevented()).toBeFalsy(); + expect($scope.select_count).toEqual(0); + expect(element).toBeClosed(); + }); + + it('should capture enter or tab when an item is focused', function() { + $scope.select_count = 0; + $scope.onSelect = function($item, $model, $label) { + $scope.select_count = $scope.select_count + 1; + }; + var element = prepareInputEl(''); + changeInputValueTo(element, 'b'); + + // down key should be captured and focus first element + triggerKeyDown(element, 40); + expect($scope.keyDownEvent.isDefaultPrevented()).toBeTruthy(); + expect(element).toBeOpenWithActive(2, 0); + + // enter key should be captured now that something is focused + triggerKeyDown(element, 13); + expect($scope.keyDownEvent.isDefaultPrevented()).toBeTruthy(); + expect($scope.select_count).toEqual(1); + }); + + describe('minLength set to 0', function() { + it('should open typeahead if input is changed to empty string if defined threshold is 0', function() { + var element = prepareInputEl(''); + changeInputValueTo(element, ''); + expect(element).toBeOpenWithActive(3, 0); + }); + + it('should open typeahead when input is focused and value is empty if defined threshold is 0', function () { + var element = prepareInputEl(''); + var inputEl = findInput(element); + inputEl.focus(); + $timeout.flush(); + $scope.$digest(); + expect(element).toBeOpenWithActive(3, 0); + }); + }); + + describe('event listeners', function() { + afterEach(function() { + angular.element($window).off('resize'); + $document.find('body').off('scroll'); + }); + + it('should register event listeners when attached to body', function() { + spyOn(window, 'addEventListener'); + spyOn(document.body, 'addEventListener'); + + var element = prepareInputEl(''); + + expect(window.addEventListener).toHaveBeenCalledWith('resize', jasmine.any(Function), false); + expect(document.body.addEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function), false); + }); + + it('should remove event listeners when attached to body', function() { + spyOn(window, 'removeEventListener'); + spyOn(document.body, 'removeEventListener'); + + var element = prepareInputEl(''); + $scope.$destroy(); + + expect(window.removeEventListener).toHaveBeenCalledWith('resize', jasmine.any(Function), false); + expect(document.body.removeEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function), false); + }); + }); +}); + +describe('typeahead tests', function() { + it('should allow directives in template to require parent controller', function() { + module('ui.bootstrap.typeahead'); + module('ngSanitize'); + module('uib/template/typeahead/typeahead-popup.html'); + module(function($compileProvider) { + $compileProvider + .directive('uibCustomParent', function() { + return { + controller: function() { + this.text = 'foo'; + } + }; + }) + .directive('uibCustomDirective', function() { + return { + require: '^uibCustomParent', + link: function(scope, element, attrs, ctrl) { + scope.text = ctrl.text; + } + }; + }); + }); + + inject(function($compile, $rootScope, $sniffer, $templateCache) { + var element; + var $scope = $rootScope.$new(); + $templateCache.put('uib/template/typeahead/typeahead-match.html', '{{text}}'); + $scope.states = [ + {code: 'AL', name: 'Alaska'}, + {code: 'CL', name: 'California'} + ]; + + element = $compile('')($scope); + $rootScope.$digest(); + + var inputEl = element.find('input'); + inputEl.val('Al'); + inputEl.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); + $scope.$digest(); + + expect(element.find('ul.dropdown-menu li').eq(0).find('[uib-custom-directive]').text()).toEqual('foo'); + }); + }); +}); diff --git a/src/typeahead/typeahead.css b/src/typeahead/typeahead.css new file mode 100644 index 0000000000..8e2463fe4f --- /dev/null +++ b/src/typeahead/typeahead.css @@ -0,0 +1,3 @@ +[uib-typeahead-popup].dropdown-menu { + display: block; +} diff --git a/src/typeahead/typeahead.js b/src/typeahead/typeahead.js index f03e7383bc..71b807c817 100644 --- a/src/typeahead/typeahead.js +++ b/src/typeahead/typeahead.js @@ -1,310 +1,693 @@ -angular.module('ui.bootstrap.typeahead', ['ui.bootstrap.position']) +angular.module('ui.bootstrap.typeahead', ['ui.bootstrap.debounce', 'ui.bootstrap.position']) /** * A helper service that can parse typeahead's syntax (string provided by users) * Extracted to a separate service for ease of unit testing */ - .factory('typeaheadParser', ['$parse', function ($parse) { - - // 00000111000000000000022200000000000000003333333333333330000000000044000 - var TYPEAHEAD_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/; - - return { - parse:function (input) { + .factory('uibTypeaheadParser', ['$parse', function($parse) { + // 000001111111100000000000002222222200000000000000003333333333333330000000000044444444000 + var TYPEAHEAD_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+([\s\S]+?)$/; + return { + parse: function(input) { + var match = input.match(TYPEAHEAD_REGEXP); + if (!match) { + throw new Error( + 'Expected typeahead specification in form of "_modelValue_ (as _label_)? for _item_ in _collection_"' + + ' but got "' + input + '".'); + } - var match = input.match(TYPEAHEAD_REGEXP), modelMapper, viewMapper, source; - if (!match) { - throw new Error( - "Expected typeahead specification in form of '_modelValue_ (as _label_)? for _item_ in _collection_'" + - " but got '" + input + "'."); + return { + itemName: match[3], + source: $parse(match[4]), + viewMapper: $parse(match[2] || match[1]), + modelMapper: $parse(match[1]) + }; } + }; + }]) - return { - itemName:match[3], - source:$parse(match[4]), - viewMapper:$parse(match[2] || match[1]), - modelMapper:$parse(match[1]) - }; + .controller('UibTypeaheadController', ['$scope', '$element', '$attrs', '$compile', '$parse', '$q', '$timeout', '$document', '$window', '$rootScope', '$$debounce', '$uibPosition', 'uibTypeaheadParser', + function(originalScope, element, attrs, $compile, $parse, $q, $timeout, $document, $window, $rootScope, $$debounce, $position, typeaheadParser) { + var HOT_KEYS = [9, 13, 27, 38, 40]; + var eventDebounceTime = 200; + var modelCtrl, ngModelOptions; + //SUPPORTED ATTRIBUTES (OPTIONS) + + //minimal no of characters that needs to be entered before typeahead kicks-in + var minLength = originalScope.$eval(attrs.typeaheadMinLength); + if (!minLength && minLength !== 0) { + minLength = 1; } - }; -}]) - .directive('typeahead', ['$compile', '$parse', '$q', '$timeout', '$document', '$position', 'typeaheadParser', function ($compile, $parse, $q, $timeout, $document, $position, typeaheadParser) { + originalScope.$watch(attrs.typeaheadMinLength, function (newVal) { + minLength = !newVal && newVal !== 0 ? 1 : newVal; + }); - var HOT_KEYS = [9, 13, 27, 38, 40]; + //minimal wait time after last character typed before typeahead kicks-in + var waitTime = originalScope.$eval(attrs.typeaheadWaitMs) || 0; - return { - require:'ngModel', - link:function (originalScope, element, attrs, modelCtrl) { + //should it restrict model values to the ones selected from the popup only? + var isEditable = originalScope.$eval(attrs.typeaheadEditable) !== false; + originalScope.$watch(attrs.typeaheadEditable, function (newVal) { + isEditable = newVal !== false; + }); - //SUPPORTED ATTRIBUTES (OPTIONS) + //binding to a variable that indicates if matches are being retrieved asynchronously + var isLoadingSetter = $parse(attrs.typeaheadLoading).assign || angular.noop; - //minimal no of characters that needs to be entered before typeahead kicks-in - var minSearch = originalScope.$eval(attrs.typeaheadMinLength) || 1; + //a function to determine if an event should cause selection + var isSelectEvent = attrs.typeaheadShouldSelect ? $parse(attrs.typeaheadShouldSelect) : function(scope, vals) { + var evt = vals.$event; + return evt.which === 13 || evt.which === 9; + }; - //minimal wait time after last character typed before typehead kicks-in - var waitTime = originalScope.$eval(attrs.typeaheadWaitMs) || 0; + //a callback executed when a match is selected + var onSelectCallback = $parse(attrs.typeaheadOnSelect); - //should it restrict model values to the ones selected from the popup only? - var isEditable = originalScope.$eval(attrs.typeaheadEditable) !== false; + //should it select highlighted popup value when losing focus? + var isSelectOnBlur = angular.isDefined(attrs.typeaheadSelectOnBlur) ? originalScope.$eval(attrs.typeaheadSelectOnBlur) : false; - //binding to a variable that indicates if matches are being retrieved asynchronously - var isLoadingSetter = $parse(attrs.typeaheadLoading).assign || angular.noop; + //binding to a variable that indicates if there were no results after the query is completed + var isNoResultsSetter = $parse(attrs.typeaheadNoResults).assign || angular.noop; - //a callback executed when a match is selected - var onSelectCallback = $parse(attrs.typeaheadOnSelect); + var inputFormatter = attrs.typeaheadInputFormatter ? $parse(attrs.typeaheadInputFormatter) : undefined; - var inputFormatter = attrs.typeaheadInputFormatter ? $parse(attrs.typeaheadInputFormatter) : undefined; + var appendToBody = attrs.typeaheadAppendToBody ? originalScope.$eval(attrs.typeaheadAppendToBody) : false; - //INTERNAL VARIABLES + var appendTo = attrs.typeaheadAppendTo ? + originalScope.$eval(attrs.typeaheadAppendTo) : null; - //model setter executed upon match selection - var $setModelValue = $parse(attrs.ngModel).assign; + var focusFirst = originalScope.$eval(attrs.typeaheadFocusFirst) !== false; - //expressions used by typeahead - var parserResult = typeaheadParser.parse(attrs.typeahead); + //If input matches an item of the list exactly, select it automatically + var selectOnExact = attrs.typeaheadSelectOnExact ? originalScope.$eval(attrs.typeaheadSelectOnExact) : false; + //binding to a variable that indicates if dropdown is open + var isOpenSetter = $parse(attrs.typeaheadIsOpen).assign || angular.noop; - //pop-up element used to display matches - var popUpEl = angular.element(''); - popUpEl.attr({ - matches: 'matches', - active: 'activeIdx', - select: 'select(activeIdx)', - query: 'query', - position: 'position' - }); - //custom item template - if (angular.isDefined(attrs.typeaheadTemplateUrl)) { - popUpEl.attr('template-url', attrs.typeaheadTemplateUrl); + var showHint = originalScope.$eval(attrs.typeaheadShowHint) || false; + + //INTERNAL VARIABLES + + //model setter executed upon match selection + var parsedModel = $parse(attrs.ngModel); + var invokeModelSetter = $parse(attrs.ngModel + '($$$p)'); + var $setModelValue = function(scope, newValue) { + if (angular.isFunction(parsedModel(originalScope)) && + ngModelOptions.getOption('getterSetter')) { + return invokeModelSetter(scope, {$$$p: newValue}); } - //create a child scope for the typeahead directive so we are not polluting original scope - //with typeahead-specific data (matches, query etc.) - var scope = originalScope.$new(); - originalScope.$on('$destroy', function(){ - scope.$destroy(); - }); + return parsedModel.assign(scope, newValue); + }; - var resetMatches = function() { - scope.matches = []; - scope.activeIdx = -1; - }; + //expressions used by typeahead + var parserResult = typeaheadParser.parse(attrs.uibTypeahead); + + var hasFocus; + + //Used to avoid bug in iOS webview where iOS keyboard does not fire + //mousedown & mouseup events + //Issue #3699 + var selected; + + //create a child scope for the typeahead directive so we are not polluting original scope + //with typeahead-specific data (matches, query etc.) + var scope = originalScope.$new(); + var offDestroy = originalScope.$on('$destroy', function() { + scope.$destroy(); + }); + scope.$on('$destroy', offDestroy); + + // WAI-ARIA + var popupId = 'typeahead-' + scope.$id + '-' + Math.floor(Math.random() * 10000); + element.attr({ + 'aria-autocomplete': 'list', + 'aria-expanded': false, + 'aria-owns': popupId + }); + + var inputsContainer, hintInputElem; + //add read-only input to show hint + if (showHint) { + inputsContainer = angular.element(''); + inputsContainer.css('position', 'relative'); + element.after(inputsContainer); + hintInputElem = element.clone(); + hintInputElem.attr('placeholder', ''); + hintInputElem.attr('tabindex', '-1'); + hintInputElem.val(''); + hintInputElem.css({ + 'position': 'absolute', + 'top': '0px', + 'left': '0px', + 'border-color': 'transparent', + 'box-shadow': 'none', + 'opacity': 1, + 'background': 'none 0% 0% / auto repeat scroll padding-box border-box rgb(255, 255, 255)', + 'color': '#999' + }); + element.css({ + 'position': 'relative', + 'vertical-align': 'top', + 'background-color': 'transparent' + }); - var getMatchesAsync = function(inputValue) { + if (hintInputElem.attr('id')) { + hintInputElem.removeAttr('id'); // remove duplicate id if present. + } + inputsContainer.append(hintInputElem); + hintInputElem.after(element); + } - var locals = {$viewValue: inputValue}; - isLoadingSetter(originalScope, true); - $q.when(parserResult.source(scope, locals)).then(function(matches) { + //pop-up element used to display matches + var popUpEl = angular.element(''); + popUpEl.attr({ + id: popupId, + matches: 'matches', + active: 'activeIdx', + select: 'select(activeIdx, evt)', + 'move-in-progress': 'moveInProgress', + query: 'query', + position: 'position', + 'assign-is-open': 'assignIsOpen(isOpen)', + debounce: 'debounceUpdate' + }); + //custom item template + if (angular.isDefined(attrs.typeaheadTemplateUrl)) { + popUpEl.attr('template-url', attrs.typeaheadTemplateUrl); + } - //it might happen that several async queries were in progress if a user were typing fast - //but we are interested only in responses that correspond to the current view value - if (inputValue === modelCtrl.$viewValue) { - if (matches.length > 0) { + if (angular.isDefined(attrs.typeaheadPopupTemplateUrl)) { + popUpEl.attr('popup-template-url', attrs.typeaheadPopupTemplateUrl); + } - scope.activeIdx = 0; - scope.matches.length = 0; + var resetHint = function() { + if (showHint) { + hintInputElem.val(''); + } + }; - //transform labels - for(var i=0; i index && inputValue) { + return inputValue.toUpperCase() === scope.matches[index].label.toUpperCase(); + } - //we need to propagate user's query so we can higlight matches - scope.query = undefined; + return false; + }; - //Declare the timeout promise var outside the function scope so that stacked calls can be cancelled later - var timeoutPromise; + var getMatchesAsync = function(inputValue, evt) { + var locals = {$viewValue: inputValue}; + isLoadingSetter(originalScope, true); + isNoResultsSetter(originalScope, false); + $q.when(parserResult.source(originalScope, locals)).then(function(matches) { + //it might happen that several async queries were in progress if a user were typing fast + //but we are interested only in responses that correspond to the current view value + var onCurrentRequest = inputValue === modelCtrl.$viewValue; + if (onCurrentRequest && hasFocus) { + if (matches && matches.length > 0) { + scope.activeIdx = focusFirst ? 0 : -1; + isNoResultsSetter(originalScope, false); + scope.matches.length = 0; + + //transform labels + for (var i = 0; i < matches.length; i++) { + locals[parserResult.itemName] = matches[i]; + scope.matches.push({ + id: getMatchId(i), + label: parserResult.viewMapper(scope, locals), + model: matches[i] + }); + } - //plug into $parsers pipeline to open a typeahead on view changes initiated from DOM - //$parsers kick-in on all the changes coming from the view as well as manually triggered by $setViewValue - modelCtrl.$parsers.push(function (inputValue) { + scope.query = inputValue; + //position pop-up with matches - we need to re-calculate its position each time we are opening a window + //with matches as a pop-up might be absolute-positioned and position of an input might have changed on a page + //due to other elements being rendered + recalculatePosition(); + + element.attr('aria-expanded', true); + + //Select the single remaining option if user input matches + if (selectOnExact && scope.matches.length === 1 && inputIsExactMatch(inputValue, 0)) { + if (angular.isNumber(scope.debounceUpdate) || angular.isObject(scope.debounceUpdate)) { + $$debounce(function() { + scope.select(0, evt); + }, angular.isNumber(scope.debounceUpdate) ? scope.debounceUpdate : scope.debounceUpdate['default']); + } else { + scope.select(0, evt); + } + } - resetMatches(); - if (inputValue && inputValue.length >= minSearch) { - if (waitTime > 0) { - if (timeoutPromise) { - $timeout.cancel(timeoutPromise);//cancel previous timeout + if (showHint) { + var firstLabel = scope.matches[0].label; + if (angular.isString(inputValue) && + inputValue.length > 0 && + firstLabel.slice(0, inputValue.length).toUpperCase() === inputValue.toUpperCase()) { + hintInputElem.val(inputValue + firstLabel.slice(inputValue.length)); + } else { + hintInputElem.val(''); + } } - timeoutPromise = $timeout(function () { - getMatchesAsync(inputValue); - }, waitTime); } else { - getMatchesAsync(inputValue); + resetMatches(); + isNoResultsSetter(originalScope, true); } } - - return isEditable ? inputValue : undefined; + if (onCurrentRequest) { + isLoadingSetter(originalScope, false); + } + }, function() { + resetMatches(); + isLoadingSetter(originalScope, false); + isNoResultsSetter(originalScope, true); }); + }; - modelCtrl.$formatters.push(function (modelValue) { + // bind events only if appendToBody params exist - performance feature + if (appendToBody) { + angular.element($window).on('resize', fireRecalculating); + $document.find('body').on('scroll', fireRecalculating); + } - var candidateViewValue, emptyViewValue; - var locals = {}; + // Declare the debounced function outside recalculating for + // proper debouncing + var debouncedRecalculate = $$debounce(function() { + // if popup is visible + if (scope.matches.length) { + recalculatePosition(); + } - if (inputFormatter) { + scope.moveInProgress = false; + }, eventDebounceTime); - locals['$model'] = modelValue; - return inputFormatter(originalScope, locals); + // Default progress type + scope.moveInProgress = false; - } else { + function fireRecalculating() { + if (!scope.moveInProgress) { + scope.moveInProgress = true; + scope.$digest(); + } - //it might happen that we don't have enough info to properly render input value - //we need to check for this situation and simply return model value if we can't apply custom formatting - locals[parserResult.itemName] = modelValue; - candidateViewValue = parserResult.viewMapper(originalScope, locals); - locals[parserResult.itemName] = undefined; - emptyViewValue = parserResult.viewMapper(originalScope, locals); + debouncedRecalculate(); + } - return candidateViewValue!== emptyViewValue ? candidateViewValue : modelValue; - } + // recalculate actual position and set new values to scope + // after digest loop is popup in right position + function recalculatePosition() { + scope.position = appendToBody ? $position.offset(element) : $position.position(element); + scope.position.top += element.prop('offsetHeight'); + } + + //we need to propagate user's query so we can higlight matches + scope.query = undefined; + + //Declare the timeout promise var outside the function scope so that stacked calls can be cancelled later + var timeoutPromise; + + var scheduleSearchWithTimeout = function(inputValue) { + timeoutPromise = $timeout(function() { + getMatchesAsync(inputValue); + }, waitTime); + }; + + var cancelPreviousTimeout = function() { + if (timeoutPromise) { + $timeout.cancel(timeoutPromise); + } + }; + + resetMatches(); + + scope.assignIsOpen = function (isOpen) { + isOpenSetter(originalScope, isOpen); + }; + + scope.select = function(activeIdx, evt) { + //called from within the $digest() cycle + var locals = {}; + var model, item; + + selected = true; + locals[parserResult.itemName] = item = scope.matches[activeIdx].model; + model = parserResult.modelMapper(originalScope, locals); + $setModelValue(originalScope, model); + modelCtrl.$setValidity('editable', true); + modelCtrl.$setValidity('parse', true); + + onSelectCallback(originalScope, { + $item: item, + $model: model, + $label: parserResult.viewMapper(originalScope, locals), + $event: evt }); - scope.select = function (activeIdx) { - //called from within the $digest() cycle - var locals = {}; - var model, item; + resetMatches(); - locals[parserResult.itemName] = item = scope.matches[activeIdx].model; - model = parserResult.modelMapper(originalScope, locals); - $setModelValue(originalScope, model); + //return focus to the input element if a match was selected via a mouse click event + // use timeout to avoid $rootScope:inprog error + if (scope.$eval(attrs.typeaheadFocusOnSelect) !== false) { + $timeout(function() { element[0].focus(); }, 0, false); + } + }; - onSelectCallback(originalScope, { - $item: item, - $model: model, - $label: parserResult.viewMapper(originalScope, locals) - }); + //bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(27) + element.on('keydown', function(evt) { + //typeahead is open and an "interesting" key was pressed + if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) { + return; + } - //return focus to the input element if a mach was selected via a mouse click event + var shouldSelect = isSelectEvent(originalScope, {$event: evt}); + + /** + * if there's nothing selected (i.e. focusFirst) and enter or tab is hit + * or + * shift + tab is pressed to bring focus to the previous element + * then clear the results + */ + if (scope.activeIdx === -1 && shouldSelect || evt.which === 9 && !!evt.shiftKey) { resetMatches(); - element[0].focus(); - }; + scope.$digest(); + return; + } - //bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(27) - element.bind('keydown', function (evt) { + evt.preventDefault(); + var target; + switch (evt.which) { + case 27: // escape + evt.stopPropagation(); - //typeahead is open and an "interesting" key was pressed - if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) { - return; + resetMatches(); + originalScope.$digest(); + break; + case 38: // up arrow + scope.activeIdx = (scope.activeIdx > 0 ? scope.activeIdx : scope.matches.length) - 1; + scope.$digest(); + target = popUpEl[0].querySelectorAll('.uib-typeahead-match')[scope.activeIdx]; + target.parentNode.scrollTop = target.offsetTop; + break; + case 40: // down arrow + scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length; + scope.$digest(); + target = popUpEl[0].querySelectorAll('.uib-typeahead-match')[scope.activeIdx]; + target.parentNode.scrollTop = target.offsetTop; + break; + default: + if (shouldSelect) { + scope.$apply(function() { + if (angular.isNumber(scope.debounceUpdate) || angular.isObject(scope.debounceUpdate)) { + $$debounce(function() { + scope.select(scope.activeIdx, evt); + }, angular.isNumber(scope.debounceUpdate) ? scope.debounceUpdate : scope.debounceUpdate['default']); + } else { + scope.select(scope.activeIdx, evt); + } + }); + } + } + }); + + element.on('focus', function (evt) { + hasFocus = true; + if (minLength === 0 && !modelCtrl.$viewValue) { + $timeout(function() { + getMatchesAsync(modelCtrl.$viewValue, evt); + }, 0); + } + }); + + element.on('blur', function(evt) { + if (isSelectOnBlur && scope.matches.length && scope.activeIdx !== -1 && !selected) { + selected = true; + scope.$apply(function() { + if (angular.isObject(scope.debounceUpdate) && angular.isNumber(scope.debounceUpdate.blur)) { + $$debounce(function() { + scope.select(scope.activeIdx, evt); + }, scope.debounceUpdate.blur); + } else { + scope.select(scope.activeIdx, evt); + } + }); + } + if (!isEditable && modelCtrl.$error.editable) { + modelCtrl.$setViewValue(); + scope.$apply(function() { + // Reset validity as we are clearing + modelCtrl.$setValidity('editable', true); + modelCtrl.$setValidity('parse', true); + }); + element.val(''); + } + hasFocus = false; + selected = false; + }); + + // Keep reference to click handler to unbind it. + var dismissClickHandler = function(evt) { + // Issue #3973 + // Firefox treats right click as a click on document + if (element[0] !== evt.target && evt.which !== 3 && scope.matches.length !== 0) { + resetMatches(); + if (!$rootScope.$$phase) { + originalScope.$digest(); } + } + }; - evt.preventDefault(); + $document.on('click', dismissClickHandler); - if (evt.which === 40) { - scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length; - scope.$digest(); + originalScope.$on('$destroy', function() { + $document.off('click', dismissClickHandler); + if (appendToBody || appendTo) { + $popup.remove(); + } - } else if (evt.which === 38) { - scope.activeIdx = (scope.activeIdx ? scope.activeIdx : scope.matches.length) - 1; - scope.$digest(); + if (appendToBody) { + angular.element($window).off('resize', fireRecalculating); + $document.find('body').off('scroll', fireRecalculating); + } + // Prevent jQuery cache memory leak + popUpEl.remove(); + + if (showHint) { + inputsContainer.remove(); + } + }); - } else if (evt.which === 13 || evt.which === 9) { - scope.$apply(function () { - scope.select(scope.activeIdx); - }); + var $popup = $compile(popUpEl)(scope); - } else if (evt.which === 27) { - evt.stopPropagation(); + if (appendToBody) { + $document.find('body').append($popup); + } else if (appendTo) { + angular.element(appendTo).eq(0).append($popup); + } else { + element.after($popup); + } + + this.init = function(_modelCtrl) { + modelCtrl = _modelCtrl; + ngModelOptions = extractOptions(modelCtrl); + + scope.debounceUpdate = $parse(ngModelOptions.getOption('debounce'))(originalScope); + //plug into $parsers pipeline to open a typeahead on view changes initiated from DOM + //$parsers kick-in on all the changes coming from the view as well as manually triggered by $setViewValue + modelCtrl.$parsers.unshift(function(inputValue) { + hasFocus = true; + + if (minLength === 0 || inputValue && inputValue.length >= minLength) { + if (waitTime > 0) { + cancelPreviousTimeout(); + scheduleSearchWithTimeout(inputValue); + } else { + getMatchesAsync(inputValue); + } + } else { + isLoadingSetter(originalScope, false); + cancelPreviousTimeout(); resetMatches(); - scope.$digest(); } + + if (isEditable) { + return inputValue; + } + + if (!inputValue) { + // Reset in case user had typed something previously. + modelCtrl.$setValidity('editable', true); + return null; + } + + modelCtrl.$setValidity('editable', false); + return undefined; }); - $document.bind('click', function(){ - resetMatches(); - scope.$digest(); + modelCtrl.$formatters.push(function(modelValue) { + var candidateViewValue, emptyViewValue; + var locals = {}; + + // The validity may be set to false via $parsers (see above) if + // the model is restricted to selected values. If the model + // is set manually it is considered to be valid. + if (!isEditable) { + modelCtrl.$setValidity('editable', true); + } + + if (inputFormatter) { + locals.$model = modelValue; + return inputFormatter(originalScope, locals); + } + + //it might happen that we don't have enough info to properly render input value + //we need to check for this situation and simply return model value if we can't apply custom formatting + locals[parserResult.itemName] = modelValue; + candidateViewValue = parserResult.viewMapper(originalScope, locals); + locals[parserResult.itemName] = undefined; + emptyViewValue = parserResult.viewMapper(originalScope, locals); + + return candidateViewValue !== emptyViewValue ? candidateViewValue : modelValue; }); + }; + + function extractOptions(ngModelCtrl) { + var ngModelOptions; - element.after($compile(popUpEl)(scope)); + if (angular.version.minor < 6) { // in angular < 1.6 $options could be missing + // guarantee a value + ngModelOptions = ngModelCtrl.$options || {}; + + // mimic 1.6+ api + ngModelOptions.getOption = function (key) { + return ngModelOptions[key]; + }; + } else { // in angular >=1.6 $options is always present + ngModelOptions = ngModelCtrl.$options; + } + + return ngModelOptions; } - }; + }]) -}]) + .directive('uibTypeahead', function() { + return { + controller: 'UibTypeaheadController', + require: ['ngModel', 'uibTypeahead'], + link: function(originalScope, element, attrs, ctrls) { + ctrls[1].init(ctrls[0]); + } + }; + }) - .directive('typeaheadPopup', function () { + .directive('uibTypeaheadPopup', ['$$debounce', function($$debounce) { return { - restrict:'E', - scope:{ - matches:'=', - query:'=', - active:'=', - position:'=', - select:'&' + scope: { + matches: '=', + query: '=', + active: '=', + position: '&', + moveInProgress: '=', + select: '&', + assignIsOpen: '&', + debounce: '&' }, - replace:true, - templateUrl:'template/typeahead/typeahead-popup.html', - link:function (scope, element, attrs) { - + replace: true, + templateUrl: function(element, attrs) { + return attrs.popupTemplateUrl || 'uib/template/typeahead/typeahead-popup.html'; + }, + link: function(scope, element, attrs) { scope.templateUrl = attrs.templateUrl; - scope.isOpen = function () { - return scope.matches.length > 0; + scope.isOpen = function() { + var isDropdownOpen = scope.matches.length > 0; + scope.assignIsOpen({ isOpen: isDropdownOpen }); + return isDropdownOpen; }; - scope.isActive = function (matchIdx) { - return scope.active == matchIdx; + scope.isActive = function(matchIdx) { + return scope.active === matchIdx; }; - scope.selectActive = function (matchIdx) { + scope.selectActive = function(matchIdx) { scope.active = matchIdx; }; - scope.selectMatch = function (activeIdx) { - scope.select({activeIdx:activeIdx}); + scope.selectMatch = function(activeIdx, evt) { + var debounce = scope.debounce(); + if (angular.isNumber(debounce) || angular.isObject(debounce)) { + $$debounce(function() { + scope.select({activeIdx: activeIdx, evt: evt}); + }, angular.isNumber(debounce) ? debounce : debounce['default']); + } else { + scope.select({activeIdx: activeIdx, evt: evt}); + } }; } }; - }) + }]) - .directive('typeaheadMatch', ['$http', '$templateCache', '$compile', '$parse', function ($http, $templateCache, $compile, $parse) { + .directive('uibTypeaheadMatch', ['$templateRequest', '$compile', '$parse', function($templateRequest, $compile, $parse) { return { - restrict:'E', - scope:{ - index:'=', - match:'=', - query:'=' + scope: { + index: '=', + match: '=', + query: '=' }, - link:function (scope, element, attrs) { - var tplUrl = $parse(attrs.templateUrl)(scope.$parent) || 'template/typeahead/typeahead-match.html'; - $http.get(tplUrl, {cache: $templateCache}).success(function(tplContent){ - element.replaceWith($compile(tplContent.trim())(scope)); + link: function(scope, element, attrs) { + var tplUrl = $parse(attrs.templateUrl)(scope.$parent) || 'uib/template/typeahead/typeahead-match.html'; + $templateRequest(tplUrl).then(function(tplContent) { + var tplEl = angular.element(tplContent.trim()); + element.replaceWith(tplEl); + $compile(tplEl)(scope); }); } }; }]) - .filter('typeaheadHighlight', function() { + .filter('uibTypeaheadHighlight', ['$sce', '$injector', '$log', function($sce, $injector, $log) { + var isSanitizePresent; + isSanitizePresent = $injector.has('$sanitize'); function escapeRegexp(queryToEscape) { - return queryToEscape.replace(/([.?*+^$[\]\\(){}|-])/g, "\\$1"); + // Regex: capture the whole query string and replace it with the string that will be used to match + // the results, for example if the capture is "a" the result will be \a + return queryToEscape.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); + } + + function containsHtml(matchItem) { + return /<.*>/g.test(matchItem); } return function(matchItem, query) { - return query ? matchItem.replace(new RegExp(escapeRegexp(query), 'gi'), '$&') : query; + if (!isSanitizePresent && containsHtml(matchItem)) { + $log.warn('Unsafe use of typeahead please use ngSanitize'); // Warn the user about the danger + } + matchItem = query ? ('' + matchItem).replace(new RegExp(escapeRegexp(query), 'gi'), '$&') : matchItem; // Replaces the capture string with a the same string inside of a "strong" tag + if (!isSanitizePresent) { + matchItem = $sce.trustAsHtml(matchItem); // If $sanitize is not present we pack the string in a $sce object for the ng-bind-html directive + } + return matchItem; }; - }); \ No newline at end of file + }]); diff --git a/template/accordion/accordion-group.html b/template/accordion/accordion-group.html index 89338d3463..8cae8c023b 100644 --- a/template/accordion/accordion-group.html +++ b/template/accordion/accordion-group.html @@ -1,5 +1,8 @@ - - {{heading}} - - - \ No newline at end of file + + + {{heading}} + + + + + diff --git a/template/accordion/accordion.html b/template/accordion/accordion.html index f94e566f12..2a55b9bd73 100644 --- a/template/accordion/accordion.html +++ b/template/accordion/accordion.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/template/alert/alert.html b/template/alert/alert.html index 26f65b3c75..b5bade4b0d 100644 --- a/template/alert/alert.html +++ b/template/alert/alert.html @@ -1,4 +1,5 @@ - - × - - + + × + Close + + diff --git a/template/carousel/carousel.html b/template/carousel/carousel.html index 97fad9f3f1..1d76a98958 100644 --- a/template/carousel/carousel.html +++ b/template/carousel/carousel.html @@ -1,8 +1,14 @@ - - - - - - ‹ - › - + + + + previous + + + + next + + + + slide {{ $index + 1 }} of {{ slides.length }}, currently active + + diff --git a/template/carousel/slide.html b/template/carousel/slide.html index e6f51617b8..d2938998e9 100644 --- a/template/carousel/slide.html +++ b/template/carousel/slide.html @@ -1,7 +1 @@ - + diff --git a/template/datepicker/datepicker.html b/template/datepicker/datepicker.html index bc54142737..8e2d2eb806 100644 --- a/template/datepicker/datepicker.html +++ b/template/datepicker/datepicker.html @@ -1,21 +1,5 @@ - - - - - {{title}} - - - - # - {{label}} - - - - - {{ getWeekNumber(row) }} - - {{dt.label}} - - - - + + + + + diff --git a/template/datepicker/day.html b/template/datepicker/day.html new file mode 100644 index 0000000000..8a81ad3671 --- /dev/null +++ b/template/datepicker/day.html @@ -0,0 +1,30 @@ + + + + previous + {{title}} + next + + + + {{::label.abbr}} + + + + + {{ weekNumbers[$index] }} + + {{::dt.label}} + + + + diff --git a/template/datepicker/month.html b/template/datepicker/month.html new file mode 100644 index 0000000000..459fb5a126 --- /dev/null +++ b/template/datepicker/month.html @@ -0,0 +1,25 @@ + + + + previous + {{title}} + next + + + + + + {{::dt.label}} + + + + diff --git a/template/datepicker/popup.html b/template/datepicker/popup.html deleted file mode 100644 index 77b04a263e..0000000000 --- a/template/datepicker/popup.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Today - Weeks - Clear - - Close - - \ No newline at end of file diff --git a/template/datepicker/year.html b/template/datepicker/year.html new file mode 100644 index 0000000000..f50bfddd7e --- /dev/null +++ b/template/datepicker/year.html @@ -0,0 +1,25 @@ + + + + previous + {{title}} + next + + + + + + {{::dt.label}} + + + + diff --git a/template/datepickerPopup/popup.html b/template/datepickerPopup/popup.html new file mode 100644 index 0000000000..b9a501b54d --- /dev/null +++ b/template/datepickerPopup/popup.html @@ -0,0 +1,10 @@ + + + + + {{ getText('current') }} + {{ getText('clear') }} + + {{ getText('close') }} + + diff --git a/template/dialog/message.html b/template/dialog/message.html deleted file mode 100644 index c2c1db5882..0000000000 --- a/template/dialog/message.html +++ /dev/null @@ -1,9 +0,0 @@ - - {{ title }} - - - {{ message }} - - diff --git a/template/modal/window.html b/template/modal/window.html new file mode 100644 index 0000000000..9850c1b61e --- /dev/null +++ b/template/modal/window.html @@ -0,0 +1 @@ + diff --git a/template/pager/pager.html b/template/pager/pager.html new file mode 100644 index 0000000000..45c11150b2 --- /dev/null +++ b/template/pager/pager.html @@ -0,0 +1,2 @@ +{{::getText('previous')}} +{{::getText('next')}} diff --git a/template/pagination/pager.html b/template/pagination/pager.html deleted file mode 100644 index 74b77f2d8b..0000000000 --- a/template/pagination/pager.html +++ /dev/null @@ -1,5 +0,0 @@ - - - {{page.text}} - - diff --git a/template/pagination/pagination.html b/template/pagination/pagination.html index dec9c8f7cf..cf52a94e8d 100644 --- a/template/pagination/pagination.html +++ b/template/pagination/pagination.html @@ -1,4 +1,5 @@ - - {{page.text}} - - +{{::getText('first')}} +{{::getText('previous')}} +{{page.text}} +{{::getText('next')}} +{{::getText('last')}} diff --git a/template/popover/popover-html.html b/template/popover/popover-html.html new file mode 100644 index 0000000000..722fd14a72 --- /dev/null +++ b/template/popover/popover-html.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/template/popover/popover-template.html b/template/popover/popover-template.html new file mode 100644 index 0000000000..6053b807c9 --- /dev/null +++ b/template/popover/popover-template.html @@ -0,0 +1,8 @@ + + + + + + diff --git a/template/popover/popover.html b/template/popover/popover.html index 5929ee6e6a..60286c95c1 100644 --- a/template/popover/popover.html +++ b/template/popover/popover.html @@ -1,8 +1,6 @@ - - + - - - - + + + diff --git a/template/progressbar/bar.html b/template/progressbar/bar.html index 09a5a6b010..f839074a49 100644 --- a/template/progressbar/bar.html +++ b/template/progressbar/bar.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/template/progressbar/progress.html b/template/progressbar/progress.html index d390e79f7d..38ee9f79aa 100644 --- a/template/progressbar/progress.html +++ b/template/progressbar/progress.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/template/progressbar/progressbar.html b/template/progressbar/progressbar.html new file mode 100644 index 0000000000..fb8eff0ccf --- /dev/null +++ b/template/progressbar/progressbar.html @@ -0,0 +1,3 @@ + + + diff --git a/template/rating/rating.html b/template/rating/rating.html index 2c9dcc746d..bd74ffb7b2 100644 --- a/template/rating/rating.html +++ b/template/rating/rating.html @@ -1,3 +1,4 @@ - - - \ No newline at end of file + + ({{ $index < value ? '*' : ' ' }}) + + diff --git a/template/tabs/tab.html b/template/tabs/tab.html index aedd7ef379..a368f86562 100644 --- a/template/tabs/tab.html +++ b/template/tabs/tab.html @@ -1,3 +1,3 @@ - - {{heading}} + + {{heading}} diff --git a/template/tabs/tabset-titles.html b/template/tabs/tabset-titles.html deleted file mode 100644 index 560e0f743f..0000000000 --- a/template/tabs/tabset-titles.html +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/template/tabs/tabset.html b/template/tabs/tabset.html index 5e9798b2c8..e5d17f8df8 100644 --- a/template/tabs/tabset.html +++ b/template/tabs/tabset.html @@ -1,12 +1,10 @@ - - - + + - + - diff --git a/template/timepicker/timepicker.html b/template/timepicker/timepicker.html index 56ac1b5ad7..076bb2a30e 100644 --- a/template/timepicker/timepicker.html +++ b/template/timepicker/timepicker.html @@ -1,20 +1,34 @@ - - - - - - - - - - : - - {{meridian}} - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + : + + + + : + + + + {{meridian}} + + + + + + + + + + + diff --git a/template/tooltip/tooltip-html-popup.html b/template/tooltip/tooltip-html-popup.html new file mode 100644 index 0000000000..aaa1e81db2 --- /dev/null +++ b/template/tooltip/tooltip-html-popup.html @@ -0,0 +1,2 @@ + + diff --git a/template/tooltip/tooltip-html-unsafe-popup.html b/template/tooltip/tooltip-html-unsafe-popup.html deleted file mode 100644 index 09a5bd506a..0000000000 --- a/template/tooltip/tooltip-html-unsafe-popup.html +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/template/tooltip/tooltip-popup.html b/template/tooltip/tooltip-popup.html index fd51120774..e2dfc0873d 100644 --- a/template/tooltip/tooltip-popup.html +++ b/template/tooltip/tooltip-popup.html @@ -1,4 +1,2 @@ - - - - + + diff --git a/template/tooltip/tooltip-template-popup.html b/template/tooltip/tooltip-template-popup.html new file mode 100644 index 0000000000..5059765954 --- /dev/null +++ b/template/tooltip/tooltip-template-popup.html @@ -0,0 +1,4 @@ + + diff --git a/template/typeahead/typeahead-match.html b/template/typeahead/typeahead-match.html index 5a660df0f1..ea8a54b63d 100644 --- a/template/typeahead/typeahead-match.html +++ b/template/typeahead/typeahead-match.html @@ -1 +1,4 @@ - \ No newline at end of file + diff --git a/template/typeahead/typeahead-popup.html b/template/typeahead/typeahead-popup.html index 69707cefe5..64d79d23bc 100644 --- a/template/typeahead/typeahead-popup.html +++ b/template/typeahead/typeahead-popup.html @@ -1,5 +1,5 @@ - - - + + + - \ No newline at end of file +
Bootstrap components written in pure AngularJS by the AngularUI Team
- Code on Github - - Download (<%= pkg.version%>) - - Create a Build +
+ Code on Github + + Download (<%= pkg.version%>) + + Create a Build
+ For Angular 2 support, check out + + ng-bootstrap + , created by the UI Bootstrap team. +
This repository contains a set of native AngularJS directives based on - Twitter Bootstrap's markup and CSS. As a result no dependency on jQuery or Bootstrap's + Bootstrap's markup and CSS. As a result no dependency on jQuery or Bootstrap's JavaScript is required. The only required dependencies are:
Build files for all directives are distributed in several flavours: minified for production usage, un-minified for development, with or without templates. All the options are described and can be - downloaded from here. + downloaded from here. It should be noted that the -tpls files contain the templates bundled in JavaScript, while the regular version does not contain the bundled templates. For more information, check out the FAQ here and the README here.
-tpls
Alternativelly, if you are only interested in a subset of directives, you can create your own build.
Whichever method you choose the good news that the overall size of a download is very small: - <20kB for all directives (~5kB with gzip compression!)
Whichever method you choose the good news that the overall size of a download is fairly small: + 122K minified for all directives with templates and 98K without (~31kB with gzip + compression, with templates, and 28K gzipped without)
As soon as you've got all the files downloaded and included in your page you just need to declare a dependency on the ui.bootstrap module: - angular.module('myModule', ['ui.bootstrap']); +
ui.bootstrap
angular.module('myModule', ['ui.bootstrap']);
If you are using UI Bootstrap in the CSP mode, e.g. in an extension, make sure you link to the ui-bootstrap-csp.css in your HTML manually.
ui-bootstrap-csp.css
You can fork one of the plunkers from this page to see a working example of what is described here.
Since version 0.14.0 we started to prefix all our components. If you are upgrading from ui-bootstrap 0.13.4 or earlier, + check our migration guide.
Original Bootstrap's CSS depends on empty href attributes to style cursors for several components (pagination, tabs etc.). + But in AngularJS adding empty href attributes to link tags will cause unwanted route changes. This is why we need to remove empty href attributes from directive templates and as a result styling is not applied correctly. The remedy is simple, just add the following styling to your application:
href
.nav, .pagination, .carousel, .panel-title a { cursor: pointer; }
Please check our FAQ section for common problems / solutions.
+ Each of the components provided in ui-bootstrap have documentation and interactive Plunker examples. +
ui-bootstrap
+ For the directives, we list the different attributes with their default values. In addition to this, some settings have a badge on it: - <% demoModules.forEach(function(module) { %> - - - <%= module.displayName %> - (ui.bootstrap.<%= module.name %>) - - - - - <%= module.docs.html %> - - - <%= module.docs.md %> - - - - - - - Edit in plunker - - - - - <%- module.docs.html %> + + - This setting has an angular $watch listener applied to it. + B - This setting is a boolean. It doesn't need a parameter. + C - This setting can be configured globally in a constant service*. + $ - This setting expects an angular expression instead of a literal string. If the expression support a boolean / integer, you can pass it directly. + readonly - This setting is readonly. + + + + For the services (you will recognize them with the $ prefix), we list all the possible parameters you can pass to them and their default values if any. + + + * Some directives have a config service that follows the next pattern: uibDirectiveConfig. The service's settings use camel case. The services can be configured in a .config function for example. + + + <% demoModules.forEach(function(module) { %> + + + <%= module.displayName %> + (ui.bootstrap.<%= module.name %>) + + + + + <%= module.docs.html %> + + + <%= module.docs.md %> + - - - - <%- module.docs.js %> + + + + + Edit in plunker + + + + + <%- module.docs.html %> + + + + + <%- module.docs.js %> + + + + - - + + + <% }); %> - - - <% }); %> - - +
<%- module.docs.html %>
+ For the services (you will recognize them with the $ prefix), we list all the possible parameters you can pass to them and their default values if any. +
$
+ * Some directives have a config service that follows the next pattern: uibDirectiveConfig. The service's settings use camel case. The services can be configured in a .config function for example. +
uibDirectiveConfig
.config
<%- module.docs.js %>
- * describe('$exceptionHandlerProvider', function() { - * - * it('should capture log messages and exceptions', function() { - * - * module(function($exceptionHandlerProvider) { - * $exceptionHandlerProvider.mode('log'); - * }); - * - * inject(function($log, $exceptionHandler, $timeout) { - * $timeout(function() { $log.log(1); }); - * $timeout(function() { $log.log(2); throw 'banana peel'; }); - * $timeout(function() { $log.log(3); }); - * expect($exceptionHandler.errors).toEqual([]); - * expect($log.assertEmpty()); - * $timeout.flush(); - * expect($exceptionHandler.errors).toEqual(['banana peel']); - * expect($log.log.logs).toEqual([[1], [2], [3]]); - * }); - * }); - * }); - *
- * var newYearInBratislava = new TzDate(-1, '2009-12-31T23:00:00Z'); - * newYearInBratislava.getTimezoneOffset() => -60; - * newYearInBratislava.getFullYear() => 2010; - * newYearInBratislava.getMonth() => 0; - * newYearInBratislava.getDate() => 1; - * newYearInBratislava.getHours() => 0; - * newYearInBratislava.getMinutes() => 0; - *
- // controller - function MyController($scope, $http) { - $http.get('/auth.py').success(function(data) { - $scope.user = data; - }); - - this.saveMessage = function(message) { - $scope.status = 'Saving...'; - $http.post('/add-msg.py', message).success(function(response) { - $scope.status = ''; - }).error(function() { - $scope.status = 'ERROR!'; - }); - }; - } - - // testing controller - var $httpBackend; - - beforeEach(inject(function($injector) { - $httpBackend = $injector.get('$httpBackend'); - - // backend definition common for all tests - $httpBackend.when('GET', '/auth.py').respond({userId: 'userX'}, {'A-Token': 'xxx'}); - })); - - - afterEach(function() { - $httpBackend.verifyNoOutstandingExpectation(); - $httpBackend.verifyNoOutstandingRequest(); - }); - - - it('should fetch authentication token', function() { - $httpBackend.expectGET('/auth.py'); - var controller = scope.$new(MyController); - $httpBackend.flush(); - }); - - - it('should send msg to server', function() { - // now you don’t care about the authentication, but - // the controller will still send the request and - // $httpBackend will respond without you having to - // specify the expectation and response for this request - $httpBackend.expectPOST('/add-msg.py', 'message content').respond(201, ''); - - var controller = scope.$new(MyController); - $httpBackend.flush(); - controller.saveMessage('message content'); - expect(controller.status).toBe('Saving...'); - $httpBackend.flush(); - expect(controller.status).toBe(''); - }); - - - it('should send auth header', function() { - $httpBackend.expectPOST('/add-msg.py', undefined, function(headers) { - // check if the header was send, if it wasn't the expectation won't - // match the request and the test will fail - return headers['Authorization'] == 'xxx'; - }).respond(201, ''); - - var controller = scope.$new(MyController); - controller.saveMessage('whatever'); - $httpBackend.flush(); - }); -
- * afterEach($httpBackend.verifyExpectations); - *
- * afterEach($httpBackend.verifyNoOutstandingRequest); - *
- * myAppDev = angular.module('myAppDev', ['myApp', 'ngMockE2E']); - * myAppDev.run(function($httpBackend) { - * phones = [{name: 'phone1'}, {name: 'phone2'}]; - * - * // returns the current list of phones - * $httpBackend.whenGET('/phones').respond(phones); - * - * // adds a new phone to the phones array - * $httpBackend.whenPOST('/phones').respond(function(method, url, data) { - * phones.push(angular.fromJSON(data)); - * }); - * $httpBackend.whenGET(/^\/templates\//).passThrough(); - * //... - * }); - *
- * - * angular.module('myApplicationModule', []) - * .value('mode', 'app') - * .value('version', 'v1.0.1'); - * - * - * describe('MyApp', function() { - * - * // You need to load modules that you want to test, - * // it loads only the "ng" module by default. - * beforeEach(module('myApplicationModule')); - * - * - * // inject() is used to inject arguments of all given functions - * it('should provide a version', inject(function(mode, version) { - * expect(version).toEqual('v1.0.1'); - * expect(mode).toEqual('app'); - * })); - * - * - * // The inject and module method can also be used inside of the it or beforeEach - * it('should override a version and test the new version is injected', function() { - * // module() takes functions or strings (module aliases) - * module(function($provide) { - * $provide.value('version', 'overridden'); // override version here - * }); - * - * inject(function(version) { - * expect(version).toEqual('overridden'); - * }); - * )); - * }); - * - *
- var values = {name: 'misko', gender: 'male'}; - var log = []; - angular.forEach(values, function(value, key){ - this.push(key + ': ' + value); - }, log); - expect(log).toEqual(['name: misko', 'gender:male']); -
- function foo(callback) { - var result = calculateResult(); - (callback || angular.noop)(result); - } -
- function transformer(transformationFn, value) { - return (transformationFn || identity)(value); - }; -
- * // Create a new module - * var myModule = angular.module('myModule', []); - * - * // register a new service - * myModule.value('appName', 'MyCoolApp'); - * - * // configure existing services inside initialization blocks. - * myModule.config(function($locationProvider) { - * // Configure existing providers - * $locationProvider.hashPrefix('!'); - * }); - *
- * var injector = angular.injector(['ng', 'MyModule']) - *
- * // create an injector - * var $injector = angular.injector(['ng']); - * - * // use the injector to kick off your application - * // use the type inference to auto inject arguments, or use implicit injection - * $injector.invoke(function($rootScope, $compile, $document){ - * $compile($document)($rootScope); - * $rootScope.$digest(); - * }); - *
- * var $injector = angular.injector(); - * expect($injector.get('$injector')).toBe($injector); - * expect($injector.invoke(function($injector){ - * return $injector; - * }).toBe($injector); - *
- * // inferred (only works if code not minified/obfuscated) - * $inject.invoke(function(serviceA){}); - * - * // annotated - * function explicit(serviceA) {}; - * explicit.$inject = ['serviceA']; - * $inject.invoke(explicit); - * - * // inline - * $inject.invoke(['serviceA', function(serviceA){}]); - *
- * // Given - * function MyController($scope, $route) { - * // ... - * } - * - * // Then - * expect(injector.annotate(MyController)).toEqual(['$scope', '$route']); - *
- * // Given - * var MyController = function(obfuscatedScope, obfuscatedRoute) { - * // ... - * } - * // Define function dependencies - * MyController.$inject = ['$scope', '$route']; - * - * // Then - * expect(injector.annotate(MyController)).toEqual(['$scope', '$route']); - *
- * // We wish to write this (not minification / obfuscation safe) - * injector.invoke(function($compile, $rootScope) { - * // ... - * }); - * - * // We are forced to write break inlining - * var tmpFn = function(obfuscatedCompile, obfuscatedRootScope) { - * // ... - * }; - * tmpFn.$inject = ['$compile', '$rootScope']; - * injector.invoke(tempFn); - * - * // To better support inline function the inline annotation is supported - * injector.invoke(['$compile', '$rootScope', function(obfCompile, obfRootScope) { - * // ... - * }]); - * - * // Therefore - * expect(injector.annotate( - * ['$compile', '$rootScope', function(obfus_$compile, obfus_$rootScope) {}]) - * ).toEqual(['$compile', '$rootScope']); - *
- * function GreetProvider() { - * var salutation = 'Hello'; - * - * this.salutation = function(text) { - * salutation = text; - * }; - * - * this.$get = function() { - * return function (name) { - * return salutation + ' ' + name + '!'; - * }; - * }; - * } - * - * describe('Greeter', function(){ - * - * beforeEach(module(function($provide) { - * $provide.provider('greet', GreetProvider); - * }); - * - * it('should greet', inject(function(greet) { - * expect(greet('angular')).toEqual('Hello angular!'); - * })); - * - * it('should allow configuration of salutation', function() { - * module(function(greetProvider) { - * greetProvider.salutation('Ahoj'); - * }); - * inject(function(greet) { - * expect(greet('angular')).toEqual('Ahoj angular!'); - * }); - * )}; - * - * }); - *
- * var element = $compile('{{total}}')(scope); - *
{{total}}
- * var templateHTML = angular.element('{{total}}'), - * scope = ....; - * - * var clonedElement = $compile(templateHTML)(scope, function(clonedElement, scope) { - * //attach the clone to DOM document at the right place - * }); - * - * //now we have reference to the cloned DOM via `clone` - *
ngBind
ng-bind
- var $interpolate = ...; // injected - var exp = $interpolate('Hello {{name}}!'); - expect(exp({name:'Angular'}).toEqual('Hello Angular!'); -
Reload this page with open console, enter text and hit the log button...
- * var getter = $parse('user.name'); - * var setter = getter.assign; - * var context = {user:{name:'angular'}}; - * var locals = {user:{name:'local'}}; - * - * expect(getter(context)).toEqual('angular'); - * setter(context, 'newValue'); - * expect(context.user.name).toEqual('newValue'); - * expect(getter(context, locals)).toEqual('local'); - *
- * // for the purpose of this example let's assume that variables `$q` and `scope` are - * // available in the current lexical scope (they could have been injected or passed in). - * - * function asyncGreet(name) { - * var deferred = $q.defer(); - * - * setTimeout(function() { - * // since this fn executes async in a future turn of the event loop, we need to wrap - * // our code into an $apply call so that the model changes are properly observed. - * scope.$apply(function() { - * if (okToGreet(name)) { - * deferred.resolve('Hello, ' + name + '!'); - * } else { - * deferred.reject('Greeting ' + name + ' is not allowed.'); - * } - * }); - * }, 1000); - * - * return deferred.promise; - * } - * - * var promise = asyncGreet('Robin Hood'); - * promise.then(function(greeting) { - * alert('Success: ' + greeting); - * }, function(reason) { - * alert('Failed: ' + reason); - * }); - *
- * promiseB = promiseA.then(function(result) { - * return result + 1; - * }); - * - * // promiseB will be resolved immediately after promiseA is resolved and its value will be - * // the result of promiseA incremented by 1 - *
- * it('should simulate promise', inject(function($q, $rootScope) { - * var deferred = $q.defer(); - * var promise = deferred.promise; - * var resolvedValue; - * - * promise.then(function(value) { resolvedValue = value; }); - * expect(resolvedValue).toBeUndefined(); - * - * // Simulate resolving of promise - * deferred.resolve(123); - * // Note that the 'then' function does not get called synchronously. - * // This is because we want the promise API to always be async, whether or not - * // it got called synchronously or asynchronously. - * expect(resolvedValue).toBeUndefined(); - * - * // Propagate promise resolution to 'then' functions using $apply(). - * $rootScope.$apply(); - * expect(resolvedValue).toEqual(123); - * }); - *
- * promiseB = promiseA.then(function(result) { - * // success: do something and resolve promiseB - * // with the old or a new result - * return result; - * }, function(reason) { - * // error: handle the error if possible and - * // resolve promiseB with newPromiseOrValue, - * // otherwise forward the rejection to promiseB - * if (canHandle(reason)) { - * // handle the error and recover - * return newPromiseOrValue; - * } - * return $q.reject(reason); - * }); - *
$location.path() = {{$location.path()}}
$route.current.templateUrl = {{$route.current.templateUrl}}
$route.current.params = {{$route.current.params}}
$route.current.scope.name = {{$route.current.scope.name}}
$routeParams = {{$routeParams}}
- * // Given: - * // URL: http://server.com/index.html#/Chapter/1/Section/2?search=moby - * // Route: /Chapter/:chapterId/Section/:sectionId - * // - * // Then - * $routeParams ==> {chapterId:1, sectionId:2, search:'moby'} - *
- angular.injector(['ng']).invoke(function($rootScope) { - var scope = $rootScope.$new(); - scope.salutation = 'Hello'; - scope.name = 'World'; - - expect(scope.greeting).toEqual(undefined); - - scope.$watch('name', function() { - scope.greeting = scope.salutation + ' ' + scope.name + '!'; - }); // initialize the watch - - expect(scope.greeting).toEqual(undefined); - scope.name = 'Misko'; - // still old value, since watches have not been called yet - expect(scope.greeting).toEqual(undefined); - - scope.$digest(); // fire all the watches - expect(scope.greeting).toEqual('Hello Misko!'); - }); - *
- var parent = $rootScope; - var child = parent.$new(); - - parent.salutation = "Hello"; - child.name = "World"; - expect(child.salutation).toEqual('Hello'); - - child.salutation = "Welcome"; - expect(child.salutation).toEqual('Welcome'); - expect(parent.salutation).toEqual('Hello'); - *
- // let's assume that scope was dependency injected as the $rootScope - var scope = $rootScope; - scope.name = 'misko'; - scope.counter = 0; - - expect(scope.counter).toEqual(0); - scope.$watch('name', function(newValue, oldValue) { scope.counter = scope.counter + 1; }); - expect(scope.counter).toEqual(0); - - scope.$digest(); - // no variable change - expect(scope.counter).toEqual(0); - - scope.name = 'adam'; - scope.$digest(); - expect(scope.counter).toEqual(1); - *
- var scope = ...; - scope.name = 'misko'; - scope.counter = 0; - - expect(scope.counter).toEqual(0); - scope.$watch('name', function(newValue, oldValue) { - scope.counter = scope.counter + 1; - }); - expect(scope.counter).toEqual(0); - - scope.$digest(); - // no variable change - expect(scope.counter).toEqual(0); - - scope.name = 'adam'; - scope.$digest(); - expect(scope.counter).toEqual(1); - *
- var scope = ng.$rootScope.Scope(); - scope.a = 1; - scope.b = 2; - - expect(scope.$eval('a+b')).toEqual(3); - expect(scope.$eval(function(scope){ return scope.a + scope.b; })).toEqual(3); - *
- function $apply(expr) { - try { - return $eval(expr); - } catch (e) { - $exceptionHandler(e); - } finally { - $root.$digest(); - } - } - *
- * $http({method: 'GET', url: '/someUrl'}). - * success(function(data, status, headers, config) { - * // this callback will be called asynchronously - * // when the response is available - * }). - * error(function(data, status, headers, config) { - * // called asynchronously if an error occurs - * // or server returns response with an error status. - * }); - *
- * $http.get('/someUrl').success(successCallback); - * $http.post('/someUrl', data).success(successCallback); - *
- * // register the interceptor as a service - * $provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) { - * return function(promise) { - * return promise.then(function(response) { - * // do something on success - * }, function(response) { - * // do something on error - * if (canRecover(response)) { - * return responseOrNewPromise - * } - * return $q.reject(response); - * }); - * } - * }); - * - * $httpProvider.responseInterceptors.push('myHttpInterceptor'); - * - * - * // register the interceptor via an anonymous factory - * $httpProvider.responseInterceptors.push(function($q, dependency1, dependency2) { - * return function(promise) { - * // same as above - * } - * }); - *
- * ['one','two'] - *
- * )]}', - * ['one','two'] - *
http status code: {{status}}
http response data: {{data}}
- * // Filter registration - * function MyModule($provide, $filterProvider) { - * // create a service to demonstrate injection (not always needed) - * $provide.value('greet', function(name){ - * return 'Hello ' + name + '!'; - * }); - * - * // register a filter factory which uses the - * // greet service to demonstrate DI. - * $filterProvider.register('greet', function(greet){ - * // return the filter function which uses the greet service - * // to generate salutation - * return function(text) { - * // filters need to be forgiving so check input validity - * return text && greet(text) || text; - * }; - * }); - * } - *
- * it('should be the same instance', inject( - * function($filterProvider) { - * $filterProvider.register('reverse', function(){ - * return ...; - * }); - * }, - * function($filter, reverseFilter) { - * expect($filter('reverse')).toBe(reverseFilter); - * }); - *
{{ {'name':'value'} | json }}
Output: {{ numbers | limitTo:limit }}
Sorting predicate = {{predicate}}; reverse = {{reverse}}
- * - *
- * - * Disabled - * - *
- * [ng\:cloak], [ng-cloak], .ng-cloak { - * display: none; - * } - *
list={{list}}
- * - * - *
myStyle={{myStyle}}
+ Toggle last panel + Enable / Disable first panel +
The body of the accordion group grows to fit the contents
The body of the uib-accordion group grows to fit the contents
Please, to delete your account, click the button below
{{singleModel}}
{{checkModel}}
Model: {{checkModel}}
Results: {{checkResults}}
{{radioModel}}
{{radioModel || 'null'}}
Beautiful!
D'aww!
{{slide.text}}
Resize window to less than 768 pixels to display mobile menu toggle button.
Initial content
+ Define your format + +
+ Result + +
Selected date is: {{dt | date:'fullDate' }}
+ + + + +
Change options at will and press the open dialog button below!
Open Dialog
Alternatively open a simple message box:
Open Message Box
Enter a value to pass to close as the result:
close
+ Toggle button dropdown + Enable/Disable +
Modal description
You are currently on page {{currentPage}}
The selected page no: {{currentPage}}
rotate
true
force-ellipses
false
boundary-link-numbers
Page: {{bigCurrentPage}} / {{numPages}}
+ Mouseenter +
Select a tab by setting active binding to true:
+ Select second tab + Select third tab +
+ Enable / Disable third tab +
{{ model | json }}
{{ outerForm.nestedForm | json }}
Time is: {{mytime | date:'shortTime' }}
- Pellentesque {{dynamicTooltipText}}, + Pellentesque {{dynamicTooltipText}}, sit amet venenatis urna cursus eget nunc scelerisque viverra mauris, in - aliquam. Tincidunt lobortis feugiat vivamus at - left eget - arcu dictum varius duis at consectetur lorem. Vitae elementum curabitur - right - nunc sed velit dignissim sodales ut eu sem integer vitae. Turpis egestas - bottom - pharetra convallis posuere morbi leo urna, - mouse - blah blah blah, - fading - at elementum eu, facilisis sed odio morbi quis commodo odio. In cursus - delayed turpis massa tincidunt dui ut. + aliquam. Tincidunt lobortis feugiat vivamus at + fading + eget arcu dictum varius duis at consectetur lorem. Vitae elementum curabitur + show delay + nunc sed velit dignissim sodales ut eu sem integer vitae. Turpis egestas + hide delay + pharetra convallis posuere morbi leo urna, + Custom template + at elementum eu, facilisis sed odio morbi quis commodo odio.
- I can even contain HTML. Check me out! + I can even contain HTML as a + scope variable or + inline string
- Or use custom triggers, like focus: - + + I can have a custom class. Check me out!
- placement: 'top', - animation: true, - popupDelay: 0, - appendToBody: false -
Model: {{selected| json}}
Model: {{selected | json}}
Model: {{asyncSelected | json}}
Model: {{ngModelOptionsSelected | json}}
Model: {{customSelected | json}}
Model: {{customPopupSelected | json}}
{{ index }} {{ match.label }}
{{ message }}