diff --git a/misc/demo/assets/demo.css b/misc/demo/assets/demo.css index 7c58d86d10..ddad9986b5 100644 --- a/misc/demo/assets/demo.css +++ b/misc/demo/assets/demo.css @@ -9,6 +9,10 @@ body { opacity: 0; } +.ng-invalid { + border: 1px solid red !important; +} + section { padding-top: 30px; } diff --git a/src/timepicker/docs/demo.html b/src/timepicker/docs/demo.html index 172e4b6888..9fdf7452cb 100644 --- a/src/timepicker/docs/demo.html +++ b/src/timepicker/docs/demo.html @@ -1,5 +1,7 @@
- +
+ +
Time is: {{mytime | date:'shortTime' }}
diff --git a/src/timepicker/docs/demo.js b/src/timepicker/docs/demo.js index 1c456b855c..478545b5be 100644 --- a/src/timepicker/docs/demo.js +++ b/src/timepicker/docs/demo.js @@ -21,6 +21,10 @@ var TimepickerDemoCtrl = function ($scope) { $scope.mytime = d; }; + $scope.changed = function () { + console.log('Time changed to: ' + $scope.mytime); + }; + $scope.clear = function() { $scope.mytime = null; }; diff --git a/src/timepicker/test/timepicker.spec.js b/src/timepicker/test/timepicker.spec.js index 4ceeb3c115..797456724b 100644 --- a/src/timepicker/test/timepicker.spec.js +++ b/src/timepicker/test/timepicker.spec.js @@ -8,7 +8,7 @@ describe('timepicker directive', function () { $rootScope = _$rootScope_; $rootScope.time = newTime(14, 40); - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); })); @@ -82,6 +82,15 @@ describe('timepicker directive', function () { expect(getModelState()).toEqual([14, 40]); }); + it('has `selected` current time when model is initially cleared', function() { + $rootScope.time = null; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect($rootScope.time).toBe(null); + expect(getTimeState()).not.toEqual(['', '', '']); + }); + it('changes inputs when model changes value', function() { $rootScope.time = newTime(11, 50); $rootScope.$digest(); @@ -235,7 +244,7 @@ describe('timepicker directive', function () { }); it('changes only the time part when minutes change', function() { - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.time = newTime(0, 0); $rootScope.$digest(); @@ -367,7 +376,7 @@ describe('timepicker directive', function () { $rootScope.hstep = 2; $rootScope.mstep = 30; $rootScope.time = newTime(14, 0); - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); }); @@ -530,7 +539,7 @@ describe('timepicker directive', function () { beforeEach(function() { $rootScope.meridian = false; $rootScope.time = newTime(14, 10); - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); }); @@ -559,6 +568,14 @@ describe('timepicker directive', function () { expect(getModelState()).toEqual([14, 10]); expect(getMeridianTd().css('display')).toBe('none'); }); + + it('handles correctly initially empty model on parent element', function() { + $rootScope.time = null; + element = $compile('')($rootScope); + $rootScope.$digest(); + + expect($rootScope.time).toBe(null); + }); }); describe('setting timepickerConfig steps', function() { @@ -568,7 +585,7 @@ describe('timepicker directive', function () { timepickerConfig.hourStep = 2; timepickerConfig.minuteStep = 10; timepickerConfig.showMeridian = false; - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); })); afterEach(inject(function(timepickerConfig) { @@ -614,7 +631,7 @@ describe('timepicker directive', function () { angular.extend(originalConfig, timepickerConfig); timepickerConfig.meridians = ['π.μ.', 'μ.μ.']; timepickerConfig.showMeridian = true; - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); })); afterEach(inject(function(timepickerConfig) { @@ -637,10 +654,9 @@ describe('timepicker directive', function () { }); describe('user input validation', function () { - var changeInputValueTo; - beforeEach(inject(function(_$compile_, _$rootScope_, $sniffer) { + beforeEach(inject(function($sniffer) { changeInputValueTo = function (inputEl, value) { inputEl.val(value); inputEl.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); @@ -661,7 +677,7 @@ describe('timepicker directive', function () { expect(getModelState()).toEqual([14, 40]); }); - it('updates hours & pads on input blur', function() { + it('updates hours & pads on input change & pads on blur', function() { var el = getHoursInputEl(); changeInputValueTo(el, 5); @@ -673,7 +689,7 @@ describe('timepicker directive', function () { expect(getModelState()).toEqual([17, 40]); }); - it('updates minutes & pads on input blur', function() { + it('updates minutes & pads on input change & pads on blur', function() { var el = getMinutesInputEl(); changeInputValueTo(el, 9); @@ -691,6 +707,7 @@ describe('timepicker directive', function () { changeInputValueTo(el, 'pizza'); expect($rootScope.time).toBe(null); expect(el.parent().hasClass('error')).toBe(true); + expect(element.hasClass('ng-invalid-time')).toBe(true); changeInputValueTo(el, 8); el.blur(); @@ -698,6 +715,7 @@ describe('timepicker directive', function () { expect(getTimeState()).toEqual(['08', '40', 'PM']); expect(getModelState()).toEqual([20, 40]); expect(el.parent().hasClass('error')).toBe(false); + expect(element.hasClass('ng-invalid-time')).toBe(false); }); it('clears model when input minutes is invalid & alerts the UI', function() { @@ -706,16 +724,18 @@ describe('timepicker directive', function () { changeInputValueTo(el, 'pizza'); expect($rootScope.time).toBe(null); expect(el.parent().hasClass('error')).toBe(true); + 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(element.hasClass('ng-invalid-time')).toBe(false); }); it('handles 12/24H mode change', function() { $rootScope.meridian = true; - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); var el = getHoursInputEl(); @@ -723,11 +743,111 @@ describe('timepicker directive', function () { changeInputValueTo(el, '16'); expect($rootScope.time).toBe(null); expect(el.parent().hasClass('error')).toBe(true); + expect(element.hasClass('ng-invalid-time')).toBe(true); $rootScope.meridian = false; $rootScope.$digest(); expect(getTimeState(true)).toEqual(['16', '40']); expect(getModelState()).toEqual([16, 40]); + 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 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:00'; + $rootScope.$digest(); + expect(element.hasClass('ng-invalid-time')).toBe(false); + expect(getTimeState()).toEqual(['03', '30', 'PM']); + }); + + it('should be invalid when the model is not a valid string date representation', function() { + $rootScope.time = 'pizza'; + $rootScope.$digest(); + expect(element.hasClass('ng-invalid-time')).toBe(true); + }); + + it('should return valid when the model becomes valid', function() { + $rootScope.time = 'pizza'; + $rootScope.$digest(); + expect(element.hasClass('ng-invalid-time')).toBe(true); + + $rootScope.time = new Date(); + $rootScope.$digest(); + expect(element.hasClass('ng-invalid-time')).toBe(false); + }); + + it('should return valid when the model is cleared', function() { + $rootScope.time = 'pizza'; + $rootScope.$digest(); + expect(element.hasClass('ng-invalid-time')).toBe(true); + + $rootScope.time = null; + $rootScope.$digest(); + expect(element.hasClass('ng-invalid-time')).toBe(false); + }); + }); + + describe('use with `ng-required` directive', function() { + beforeEach(inject(function() { + $rootScope.time = null; + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('should be invalid initially', function() { + expect(element.hasClass('ng-invalid')).toBe(true); + }); + + it('should be valid if model has been specified', function() { + $rootScope.time = new Date(); + $rootScope.$digest(); + expect(element.hasClass('ng-invalid')).toBe(false); + }); + }); + + describe('use with `ng-change` directive', function() { + beforeEach(inject(function() { + $rootScope.changeHandler = jasmine.createSpy('changeHandler'); + $rootScope.time = new Date(); + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('should not be called initially', function() { + expect($rootScope.changeHandler).not.toHaveBeenCalled(); + }); + + it('should be called when hours / minutes buttons clicked', function() { + var btn1 = getHoursButton(true); + var btn2 = getMinutesButton(false); + + doClick(btn1, 2); + doClick(btn2, 3); + $rootScope.$digest(); + expect($rootScope.changeHandler.callCount).toBe(5); + }); + + it('should not be called when model changes programatically', function() { + $rootScope.time = new Date(); + $rootScope.$digest(); + expect($rootScope.changeHandler).not.toHaveBeenCalled(); }); }); diff --git a/src/timepicker/timepicker.js b/src/timepicker/timepicker.js index db65378f68..3e6f9064f6 100644 --- a/src/timepicker/timepicker.js +++ b/src/timepicker/timepicker.js @@ -1,14 +1,5 @@ angular.module('ui.bootstrap.timepicker', []) -.filter('pad', function() { - return function(input) { - if ( angular.isDefined(input) && input.toString().length < 2 ) { - input = '0' + input; - } - return input; - }; -}) - .constant('timepickerConfig', { hourStep: 1, minuteStep: 1, @@ -18,16 +9,18 @@ angular.module('ui.bootstrap.timepicker', []) mousewheel: true }) -.directive('timepicker', ['padFilter', '$parse', 'timepickerConfig', function (padFilter, $parse, timepickerConfig) { +.directive('timepicker', ['$parse', '$log', 'timepickerConfig', function ($parse, $log, timepickerConfig) { return { restrict: 'EA', - require:'ngModel', + require:'?^ngModel', replace: true, + scope: {}, templateUrl: 'template/timepicker/timepicker.html', - scope: { - model: '=ngModel' - }, - link: function(scope, element, attrs, ngModelCtrl) { + link: function(scope, element, attrs, ngModel) { + if ( !ngModel ) { + return; // do nothing if no ng-model + } + var selected = new Date(), meridians = timepickerConfig.meridians; var hourStep = timepickerConfig.hourStep; @@ -48,28 +41,27 @@ angular.module('ui.bootstrap.timepicker', []) scope.showMeridian = timepickerConfig.showMeridian; if (attrs.showMeridian) { scope.$parent.$watch($parse(attrs.showMeridian), function(value) { - scope.showMeridian = !! value; - - if ( ! scope.model ) { - // Reset - var dt = new Date( selected ); - var hours = getScopeHours(); - if (angular.isDefined( hours )) { - dt.setHours( hours ); + 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(); } - scope.model = new Date( dt ); } else { - refreshTemplate(); + updateTemplate(); } }); } // Get scope.hours in 24H mode if valid - function getScopeHours ( ) { + function getHoursFromTemplate ( ) { var hours = parseInt( scope.hours, 10 ); var valid = ( scope.showMeridian ) ? (hours > 0 && hours < 13) : (hours >= 0 && hours < 24); if ( !valid ) { - return; + return undefined; } if ( scope.showMeridian ) { @@ -83,14 +75,22 @@ angular.module('ui.bootstrap.timepicker', []) return hours; } + function getMinutesFromTemplate() { + var minutes = parseInt(scope.minutes, 10); + return ( minutes >= 0 && minutes < 60 ) ? minutes : undefined; + } + + function pad( value ) { + return ( angular.isDefined(value) && value.toString().length < 2 ) ? '0' + value : value; + } + // Input elements - var inputs = element.find('input'); - var hoursInputEl = inputs.eq(0), minutesInputEl = inputs.eq(1); + var inputs = element.find('input'), hoursInputEl = inputs.eq(0), minutesInputEl = inputs.eq(1); // Respond on mousewheel spin var mousewheel = (angular.isDefined(attrs.mousewheel)) ? scope.$eval(attrs.mousewheel) : timepickerConfig.mousewheel; if ( mousewheel ) { - + var isScrollingUp = function(e) { if (e.originalEvent) { e = e.originalEvent; @@ -99,7 +99,7 @@ angular.module('ui.bootstrap.timepicker', []) var delta = (e.wheelDelta) ? e.wheelDelta : -e.deltaY; return (e.detail || delta > 0); }; - + hoursInputEl.bind('mousewheel wheel', function(e) { scope.$apply( (isScrollingUp(e)) ? scope.incrementHours() : scope.decrementHours() ); e.preventDefault(); @@ -111,50 +111,54 @@ angular.module('ui.bootstrap.timepicker', []) }); } - var keyboardChange = false; 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 = getScopeHours(); + var hours = getHoursFromTemplate(); if ( angular.isDefined(hours) ) { - keyboardChange = 'h'; - if ( scope.model === null ) { - scope.model = new Date( selected ); - } - scope.model.setHours( hours ); + selected.setHours( hours ); + refresh( 'h' ); } else { - scope.model = null; - scope.validHours = false; + invalidate(true); } }; hoursInputEl.bind('blur', function(e) { - if ( scope.validHours && scope.hours < 10) { + if ( !scope.validHours && scope.hours < 10) { scope.$apply( function() { - scope.hours = padFilter( scope.hours ); + scope.hours = pad( scope.hours ); }); } }); scope.updateMinutes = function() { - var minutes = parseInt(scope.minutes, 10); - if ( minutes >= 0 && minutes < 60 ) { - keyboardChange = 'm'; - if ( scope.model === null ) { - scope.model = new Date( selected ); - } - scope.model.setMinutes( minutes ); + var minutes = getMinutesFromTemplate(); + + if ( angular.isDefined(minutes) ) { + selected.setMinutes( minutes ); + refresh( 'm' ); } else { - scope.model = null; - scope.validMinutes = false; + invalidate(undefined, true); } }; minutesInputEl.bind('blur', function(e) { - if ( scope.validMinutes && scope.minutes < 10 ) { + if ( !scope.invalidMinutes && scope.minutes < 10 ) { scope.$apply( function() { - scope.minutes = padFilter( scope.minutes ); + scope.minutes = pad( scope.minutes ); }); } }); @@ -163,38 +167,49 @@ angular.module('ui.bootstrap.timepicker', []) scope.updateMinutes = angular.noop; } - scope.$watch( function getModelTimestamp() { - return +scope.model; - }, function( timestamp ) { - if ( !isNaN( timestamp ) && timestamp > 0 ) { - selected = new Date( timestamp ); - refreshTemplate(); - } - }); + ngModel.$render = function() { + var date = ngModel.$modelValue ? new Date( ngModel.$modelValue ) : null; - function refreshTemplate() { - var hours = selected.getHours(); - if ( scope.showMeridian ) { - // Convert 24 to 12 hour system - hours = ( hours === 0 || hours === 12 ) ? 12 : hours % 12; + 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(); } - scope.hours = ( keyboardChange === 'h' ) ? hours : padFilter(hours); - scope.validHours = true; + }; - var minutes = selected.getMinutes(); - scope.minutes = ( keyboardChange === 'm' ) ? minutes : padFilter(minutes); - scope.validMinutes = true; + // Call internally when we know that model is valid. + function refresh( keyboardChange ) { + makeValid(); + ngModel.$setViewValue( new Date(selected) ); + updateTemplate( keyboardChange ); + } - scope.meridian = ( scope.showMeridian ) ? (( selected.getHours() < 12 ) ? meridians[0] : meridians[1]) : ''; + function makeValid() { + ngModel.$setValidity('time', true); + scope.invalidHours = false; + scope.invalidMinutes = false; + } - keyboardChange = false; + function updateTemplate( keyboardChange ) { + var hours = selected.getHours(), minutes = selected.getMinutes(); + + 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() ); - selected.setMinutes( dt.getMinutes() ); - scope.model = new Date( selected ); + selected.setHours( dt.getHours(), dt.getMinutes() ); + refresh(); } scope.incrementHours = function() { diff --git a/template/timepicker/timepicker.html b/template/timepicker/timepicker.html index 5ef9d0083a..56ac1b5ad7 100644 --- a/template/timepicker/timepicker.html +++ b/template/timepicker/timepicker.html @@ -6,9 +6,9 @@ - + : - +