From 7f4b40eb8bc57f03bd306330a6ad227790c33f27 Mon Sep 17 00:00:00 2001 From: Tasos Bekos Date: Thu, 16 Jan 2014 22:51:18 +0100 Subject: [PATCH] feat(datepicker): add `datepicker-mode`, `init-date` & today hint * Add two-way binded `datepicker-mode`. * Add optional `init-date` when no model value is specified. * Add hint for current date. * Use isolated scope for popup directive. * Use optional binding for `isOpen`. * Split each mode into it's own directive. Closes #1599 BREAKING CHANGE: `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` --- src/datepicker/datepicker.js | 622 ++++++++++++------------ src/datepicker/docs/demo.html | 7 +- src/datepicker/docs/demo.js | 12 +- src/datepicker/docs/readme.md | 58 ++- src/datepicker/test/datepicker.spec.js | 642 +++++++++++-------------- template/datepicker/datepicker.html | 26 +- template/datepicker/day.html | 21 + template/datepicker/month.html | 16 + template/datepicker/popup.html | 9 +- template/datepicker/year.html | 16 + 10 files changed, 690 insertions(+), 739 deletions(-) create mode 100644 template/datepicker/day.html create mode 100644 template/datepicker/month.html create mode 100644 template/datepicker/year.html diff --git a/src/datepicker/datepicker.js b/src/datepicker/datepicker.js index 692696376e..e04543aa19 100644 --- a/src/datepicker/datepicker.js +++ b/src/datepicker/datepicker.js @@ -1,12 +1,15 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) .constant('datepickerConfig', { - dayFormat: 'dd', - monthFormat: 'MMMM', - yearFormat: 'yyyy', - dayHeaderFormat: 'EEE', - dayTitleFormat: 'MMMM yyyy', - monthTitleFormat: 'yyyy', + formatDay: 'dd', + formatMonth: 'MMMM', + formatYear: 'yyyy', + formatDayHeader: 'EEE', + formatDayTitle: 'MMMM yyyy', + formatMonthTitle: 'yyyy', + datepickerMode: 'day', + minMode: 'day', + maxMode: 'year', showWeeks: true, startingDay: 0, yearRange: 20, @@ -14,247 +17,287 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) maxDate: null }) -.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; - } - - function getDaysInMonth( year, month ) { - return new Date(year, month, 0).getDate(); - } - - 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 ); +.controller('DatepickerController', ['$scope', '$attrs', '$parse', '$interpolate', '$log', 'dateFilter', 'datepickerConfig', function($scope, $attrs, $parse, $interpolate, $log, dateFilter, datepickerConfig) { + var self = this, + ngModelCtrl = { $setViewValue: angular.noop }; // nullModelCtrl; + + // Configuration attributes + angular.forEach(['formatDay', 'formatMonth', 'formatYear', 'formatDayHeader', 'formatDayTitle', 'formatMonthTitle', + 'minMode', 'maxMode', 'showWeeks', 'startingDay', 'yearRange'], function( key, index ) { + self[key] = angular.isDefined($attrs[key]) ? (index < 8 ? $interpolate($attrs[key])($scope.$parent) : $scope.$parent.$eval($attrs[key])) : datepickerConfig[key]; + }); + + // Watchable attributes + angular.forEach(['minDate', 'maxDate'], function( key ) { + if ( $attrs[key] ) { + $scope.$parent.$watch($parse($attrs[key]), function(value) { + self[key] = value ? new Date(value) : null; + self.refreshView(); + }); + } else { + self[key] = datepickerConfig[key] ? new Date(datepickerConfig[key]) : null; } - return dates; - } - - function makeDate(date, format, isSelected, isSecondary) { - return { date: date, label: dateFilter(date, format), selected: !!isSelected, secondary: !!isSecondary }; - } - - 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.datepickerMode = $scope.datepickerMode || datepickerConfig.datepickerMode; + this.currentCalendarDate = angular.isDefined($attrs.initDate) ? $scope.$parent.$eval($attrs.initDate) : new Date(); - var days = getDates(firstDate, numDates), labels = new Array(7); - for (var i = 0; i < numDates; i ++) { - var dt = 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())); - } - 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 } + this.init = function( ngModelCtrl_ ) { + ngModelCtrl = ngModelCtrl_; + + ngModelCtrl.$render = function() { + self.render(); + }; + }; + + this.render = function() { + if ( ngModelCtrl.$modelValue ) { + var date = new Date( ngModelCtrl.$modelValue ), + isValid = !isNaN(date); + + if ( isValid ) { + this.currentCalendarDate = date; + } else { + $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.'); + } + ngModelCtrl.$setValidity('date', isValid); } - ]; + this.refreshView(); + }; + + this.refreshView = function() { + if ( this.mode ) { + this._refreshView(); + + var date = ngModelCtrl.$modelValue ? new Date(ngModelCtrl.$modelValue) : null; + ngModelCtrl.$setValidity('date-disabled', !date || (this.mode && !this.isDisabled(date))); + } + }; + + this.createDateObject = function(date, format) { + var model = ngModelCtrl.$modelValue ? new Date(ngModelCtrl.$modelValue) : null; + return { + date: date, + label: dateFilter(date, format), + selected: model && this.compare(date, model) === 0, + disabled: this.isDisabled(date), + current: this.compare(date, new Date()) === 0 + }; + }; - 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}))); + this.isDisabled = function( date ) { + return ((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}))); + }; + + // Split array into smaller arrays + this.split = function(arr, size) { + var arrays = []; + while (arr.length > 0) { + arrays.push(arr.splice(0, size)); + } + return arrays; + }; + + $scope.select = function( date ) { + if ( $scope.datepickerMode === self.minMode ) { + var dt = ngModelCtrl.$modelValue ? new Date( ngModelCtrl.$modelValue ) : new Date(0, 0, 0, 0, 0, 0, 0); + dt.setFullYear( date.getFullYear(), date.getMonth(), date.getDate() ); + ngModelCtrl.$setViewValue( dt ); + ngModelCtrl.$render(); + } else { + self.currentCalendarDate = date; + $scope.datepickerMode = self.mode.previous; + } + }; + + $scope.move = function( direction ) { + var year = self.currentCalendarDate.getFullYear() + direction * (self.mode.step.years || 0), + month = self.currentCalendarDate.getMonth() + direction * (self.mode.step.months || 0); + self.currentCalendarDate.setFullYear(year, month, 1); + self.refreshView(); + }; + + $scope.toggleMode = function() { + $scope.datepickerMode = $scope.datepickerMode === self.maxMode ? self.minMode : self.mode.next; }; }]) -.directive( 'datepicker', ['dateFilter', '$parse', 'datepickerConfig', '$log', function (dateFilter, $parse, datepickerConfig, $log) { +.directive('daypicker', ['dateFilter', function (dateFilter) { 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]; + templateUrl: 'template/datepicker/day.html', + require: '^datepicker', + link: function(scope, element, attrs, ctrl) { + scope.showWeeks = ctrl.showWeeks; + + ctrl.mode = { + step: { months: 1 }, + next: 'month' + }; - if (!ngModel) { - return; // do nothing if no ng-model + function getDaysInMonth( year, month ) { + return new Date(year, month, 0).getDate(); } - // 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(); + function getDates(startDate, n) { + var dates = new Array(n), current = new Date(startDate), i = 0; + current.setHours(12); // Prevent repeated dates because of timezone bug + while ( i < n ) { + dates[i++] = new Date(current); + current.setDate( current.getDate() + 1 ); + } + return dates; } - 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(); - }); - } + ctrl._refreshView = function() { + var year = ctrl.currentCalendarDate.getFullYear(), + month = ctrl.currentCalendarDate.getMonth(), + firstDayOfMonth = new Date(year, month, 1), + difference = ctrl.startingDay - firstDayOfMonth.getDay(), + numDisplayedFromPreviousMonth = (difference > 0) ? 7 - difference : - difference, + firstDate = new Date(firstDayOfMonth), numDates = 0; - function updateShowWeekNumbers() { - scope.showWeekNumbers = mode === 0 && showWeeks; - } + if ( numDisplayedFromPreviousMonth > 0 ) { + firstDate.setDate( - numDisplayedFromPreviousMonth + 1 ); + numDates += numDisplayedFromPreviousMonth; // Previous + } + numDates += getDaysInMonth(year, month + 1); // Current + numDates += (7 - numDates % 7) % 7; // Next - // Split array into smaller arrays - function split(arr, size) { - var arrays = []; - while (arr.length > 0) { - arrays.push(arr.splice(0, size)); + var days = getDates(firstDate, numDates); + for (var i = 0; i < numDates; i ++) { + days[i] = angular.extend(ctrl.createDateObject(days[i], ctrl.formatDay), { + secondary: days[i].getMonth() !== month + }); } - return arrays; - } - function refill( updateSelected ) { - var date = null, valid = true; + scope.labels = new Array(7); + for (var j = 0; j < 7; j++) { + scope.labels[j] = dateFilter(days[j].date, ctrl.formatDayHeader); + } - if ( ngModel.$modelValue ) { - date = new Date( ngModel.$modelValue ); + scope.title = dateFilter(ctrl.currentCalendarDate, ctrl.formatDayTitle); + scope.rows = ctrl.split(days, 7); - 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; - } + if ( scope.showWeeks ) { + scope.weekNumbers = []; + var weekNumber = getISO8601WeekNumber( scope.rows[0][0].date ), + numWeeks = scope.rows.length; + while( scope.weekNumbers.push(weekNumber++) < numWeeks ) {} } - ngModel.$setValidity('date', valid); - - var currentMode = datepickerCtrl.modes[mode], data = currentMode.getVisibleDates(selected, date); - angular.forEach(data.objects, function(obj) { - obj.disabled = datepickerCtrl.isDisabled(obj.date, mode); - }); + }; - ngModel.$setValidity('date-disabled', (!date || !datepickerCtrl.isDisabled(date))); + ctrl.compare = function(date1, date2) { + return (new Date( date1.getFullYear(), date1.getMonth(), date1.getDate() ) - new Date( date2.getFullYear(), date2.getMonth(), date2.getDate() ) ); + }; - scope.rows = split(data.objects, currentMode.split); - scope.labels = data.labels || []; - scope.title = data.title; + 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; } - function setMode(value) { - mode = value; - updateShowWeekNumbers(); - refill(); - } + ctrl.refreshView(); + } + }; +}]) - ngModel.$render = function() { - refill( true ); +.directive('monthpicker', ['dateFilter', function (dateFilter) { + return { + restrict: 'EA', + replace: true, + templateUrl: 'template/datepicker/month.html', + require: '^datepicker', + link: function(scope, element, attrs, ctrl) { + ctrl.mode = { + step: { years: 1 }, + previous: 'day', + next: 'year' }; - scope.select = function( date ) { - if ( mode === 0 ) { - var dt = ngModel.$modelValue ? new Date( ngModel.$modelValue ) : new Date(0, 0, 0, 0, 0, 0, 0); - dt.setFullYear( date.getFullYear(), date.getMonth(), date.getDate() ); - ngModel.$setViewValue( dt ); - refill( true ); - } else { - selected = date; - setMode( mode - 1 ); + ctrl._refreshView = function() { + var months = new Array(12), + year = ctrl.currentCalendarDate.getFullYear(); + + for ( var i = 0; i < 12; i++ ) { + months[i] = ctrl.createDateObject(new Date(year, i, 1), ctrl.formatMonth); } + + scope.title = dateFilter(ctrl.currentCalendarDate, ctrl.formatMonthTitle); + scope.rows = ctrl.split(months, 3); }; - 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(); + + ctrl.compare = function(date1, date2) { + return new Date( date1.getFullYear(), date1.getMonth() ) - new Date( date2.getFullYear(), date2.getMonth() ); }; - scope.toggleMode = function() { - setMode( (mode + 1) % datepickerCtrl.modes.length ); + + ctrl.refreshView(); + } + }; +}]) + +.directive('yearpicker', ['dateFilter', function (dateFilter) { + return { + restrict: 'EA', + replace: true, + templateUrl: 'template/datepicker/year.html', + require: '^datepicker', + link: function(scope, element, attrs, ctrl) { + ctrl.mode = { + step: { years: ctrl.yearRange }, + previous: 'month' }; - scope.getWeekNumber = function(row) { - return ( mode === 0 && scope.showWeekNumbers && row.length === 7 ) ? getISO8601WeekNumber(row[0].date) : null; + + ctrl._refreshView = function() { + var range = this.mode.step.years, + years = new Array(range), + start = parseInt((ctrl.currentCalendarDate.getFullYear() - 1) / range, 10) * range + 1; + + for ( var i = 0; i < range; i++ ) { + years[i] = ctrl.createDateObject(new Date(start + i, 0, 1), ctrl.formatYear); + } + + scope.title = [years[0].label, years[range - 1].label].join(' - '); + scope.rows = ctrl.split(years, 5); }; - 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; - } + ctrl.compare = function(date1, date2) { + return date1.getFullYear() - date2.getFullYear(); + }; + + ctrl.refreshView(); } }; }]) +.directive( 'datepicker', function () { + return { + restrict: 'EA', + replace: true, + templateUrl: 'template/datepicker/datepicker.html', + scope: { + datepickerMode: '=?', + dateDisabled: '&' + }, + require: ['datepicker', '?^ngModel'], + controller: 'DatepickerController', + link: function(scope, element, attrs, ctrls) { + var datepickerCtrl = ctrls[0], ngModelCtrl = ctrls[1]; + + if ( ngModelCtrl ) { + datepickerCtrl.init( ngModelCtrl ); + } + } + }; +}) + .constant('datepickerPopupConfig', { dateFormat: 'yyyy-MM-dd', currentText: 'Today', - toggleWeeksText: 'Weeks', clearText: 'Clear', closeText: 'Done', closeOnDateSelection: true, @@ -262,86 +305,62 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) showButtonBar: true }) -.directive('datepickerPopup', ['$compile', '$parse', '$document', '$position', 'dateFilter', 'datepickerPopupConfig', 'datepickerConfig', -function ($compile, $parse, $document, $position, dateFilter, datepickerPopupConfig, datepickerConfig) { +.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) { - var scope = originalScope.$new(), // create a child scope so we are not polluting original one - dateFormat, - closeOnDateSelection = angular.isDefined(attrs.closeOnDateSelection) ? originalScope.$eval(attrs.closeOnDateSelection) : datepickerPopupConfig.closeOnDateSelection, - appendToBody = angular.isDefined(attrs.datepickerAppendToBody) ? originalScope.$eval(attrs.datepickerAppendToBody) : datepickerPopupConfig.appendToBody; + scope: { + isOpen: '=?', + currentText: '@', + clearText: '@', + closeText: '@' + }, + link: function(scope, element, attrs, ngModel) { + var dateFormat, + closeOnDateSelection = angular.isDefined(attrs.closeOnDateSelection) ? scope.$parent.$eval(attrs.closeOnDateSelection) : datepickerPopupConfig.closeOnDateSelection, + appendToBody = angular.isDefined(attrs.datepickerAppendToBody) ? scope.$parent.$eval(attrs.datepickerAppendToBody) : datepickerPopupConfig.appendToBody; + + scope.showButtonBar = angular.isDefined(attrs.showButtonBar) ? scope.$parent.$eval(attrs.showButtonBar) : datepickerPopupConfig.showButtonBar; + + scope.getText = function( key ) { + return scope[key + 'Text'] || datepickerPopupConfig[key + 'Text']; + }; attrs.$observe('datepickerPopup', function(value) { dateFormat = value || datepickerPopupConfig.dateFormat; ngModel.$render(); }); - scope.showButtonBar = angular.isDefined(attrs.showButtonBar) ? originalScope.$eval(attrs.showButtonBar) : datepickerPopupConfig.showButtonBar; - - originalScope.$on('$destroy', function() { - $popup.remove(); - scope.$destroy(); - }); - - attrs.$observe('currentText', function(text) { - scope.currentText = angular.isDefined(text) ? text : datepickerPopupConfig.currentText; - }); - attrs.$observe('toggleWeeksText', function(text) { - scope.toggleWeeksText = angular.isDefined(text) ? text : datepickerPopupConfig.toggleWeeksText; - }); - attrs.$observe('clearText', function(text) { - scope.clearText = angular.isDefined(text) ? text : datepickerPopupConfig.clearText; - }); - attrs.$observe('closeText', function(text) { - scope.closeText = angular.isDefined(text) ? text : datepickerPopupConfig.closeText; + // popup element used to display calendar + var popupEl = angular.element('
'); + popupEl.attr({ + 'ng-model': 'date', + 'ng-change': 'dateSelection()' }); - var getIsOpen, setIsOpen; - if ( attrs.isOpen ) { - getIsOpen = $parse(attrs.isOpen); - setIsOpen = getIsOpen.assign; - - originalScope.$watch(getIsOpen, function updateOpen(value) { - scope.isOpen = !! value; - }); + function cameltoDash( string ){ + return string.replace(/([A-Z])/g, function($1) { return '-' + $1.toLowerCase(); }); } - scope.isOpen = getIsOpen ? getIsOpen(originalScope) : false; // Initial state - function setOpen( value ) { - if (setIsOpen) { - setIsOpen(originalScope, !!value); - } else { - scope.isOpen = !!value; - } + // datepicker element + var datepickerEl = angular.element(popupEl.children()[0]); + if ( attrs.datepickerOptions ) { + angular.forEach(scope.$parent.$eval(attrs.datepickerOptions), function( value, option ) { + datepickerEl.attr( cameltoDash(option), value ); + }); } - var documentClickBind = function(event) { - if (scope.isOpen && event.target !== element[0]) { - scope.$apply(function() { - setOpen(false); + angular.forEach(['minDate', 'maxDate'], function( key ) { + if ( attrs[key] ) { + scope.$parent.$watch($parse(attrs[key]), function(value){ + scope[key] = value; }); + datepickerEl.attr(cameltoDash(key), key); } - }; - - var elementFocusBind = function() { - scope.$apply(function() { - setOpen( true ); - }); - }; - - // popup element used to display calendar - var popupEl = angular.element('
'); - popupEl.attr({ - 'ng-model': 'date', - 'ng-change': 'dateSelection()' }); - var datepickerEl = angular.element(popupEl.children()[0]), - datepickerOptions = {}; - if (attrs.datepickerOptions) { - datepickerOptions = originalScope.$eval(attrs.datepickerOptions); - datepickerEl.attr(angular.extend({}, datepickerOptions)); + if (attrs.dateDisabled) { + datepickerEl.attr('date-disabled', attrs.dateDisabled); } // TODO: reverse from dateFilter string to Date object @@ -376,8 +395,8 @@ function ($compile, $parse, $document, $position, dateFilter, datepickerPopupCon ngModel.$setViewValue(scope.date); ngModel.$render(); - if (closeOnDateSelection) { - setOpen( false ); + if ( closeOnDateSelection ) { + scope.isOpen = false; } }; @@ -391,62 +410,39 @@ function ($compile, $parse, $document, $position, dateFilter, datepickerPopupCon ngModel.$render = function() { var date = ngModel.$viewValue ? dateFilter(ngModel.$viewValue, dateFormat) : ''; element.val(date); - scope.date = ngModel.$modelValue; + scope.date = parseDate( ngModel.$modelValue ); }; - function addWatchableAttribute(attribute, scopeProperty, datepickerAttribute) { - if (attribute) { - originalScope.$watch($parse(attribute), function(value){ - scope[scopeProperty] = value; + var documentClickBind = function(event) { + if (scope.isOpen && event.target !== element[0]) { + scope.$apply(function() { + scope.isOpen = false; }); - 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 = 'show-weeks' in datepickerOptions ? datepickerOptions['show-weeks'] : datepickerConfig.showWeeks; - datepickerEl.attr('show-weeks', 'showWeeks'); - } - if (attrs.dateDisabled) { - datepickerEl.attr('date-disabled', attrs.dateDisabled); - } + }; - function updatePosition() { - scope.position = appendToBody ? $position.offset(element) : $position.position(element); - scope.position.top = scope.position.top + element.prop('offsetHeight'); - } + var openCalendar = function() { + scope.$apply(function() { + scope.isOpen = true; + }); + }; - var documentBindingInitialized = false, elementFocusInitialized = false; scope.$watch('isOpen', function(value) { if (value) { - updatePosition(); + scope.position = appendToBody ? $position.offset(element) : $position.position(element); + scope.position.top = scope.position.top + element.prop('offsetHeight'); + $document.bind('click', documentClickBind); - if(elementFocusInitialized) { - element.unbind('focus', elementFocusBind); - } + element.unbind('focus', openCalendar); element[0].focus(); - documentBindingInitialized = true; } else { - if(documentBindingInitialized) { - $document.unbind('click', documentClickBind); - } - element.bind('focus', elementFocusBind); - elementFocusInitialized = true; - } - - if ( setIsOpen ) { - setIsOpen(originalScope, value); + $document.unbind('click', documentClickBind); + element.bind('focus', openCalendar); } }); - scope.today = function() { - scope.dateSelection(new Date()); - }; - scope.clear = function() { - scope.dateSelection(null); + scope.select = function( date ) { + scope.dateSelection( date === 'today' ? new Date() : date); }; var $popup = $compile(popupEl)(scope); @@ -455,6 +451,12 @@ function ($compile, $parse, $document, $position, dateFilter, datepickerPopupCon } else { element.after($popup); } + + scope.$on('$destroy', function() { + $popup.remove(); + element.unbind('focus', openCalendar); + $document.unbind('click', documentClickBind); + }); } }; }]) diff --git a/src/datepicker/docs/demo.html b/src/datepicker/docs/demo.html index 31049873e1..09c2071a23 100644 --- a/src/datepicker/docs/demo.html +++ b/src/datepicker/docs/demo.html @@ -3,16 +3,14 @@

Inline

-
- -
+

Popup

- + @@ -28,7 +26,6 @@

Popup


-
\ No newline at end of file diff --git a/src/datepicker/docs/demo.js b/src/datepicker/docs/demo.js index 5ae9a335e0..c9e7a64442 100644 --- a/src/datepicker/docs/demo.js +++ b/src/datepicker/docs/demo.js @@ -4,11 +4,6 @@ var DatepickerDemoCtrl = function ($scope) { }; $scope.today(); - $scope.showWeeks = true; - $scope.toggleWeeks = function () { - $scope.showWeeks = ! $scope.showWeeks; - }; - $scope.clear = function () { $scope.dt = null; }; @@ -19,7 +14,7 @@ var DatepickerDemoCtrl = function ($scope) { }; $scope.toggleMin = function() { - $scope.minDate = ( $scope.minDate ) ? null : new Date(); + $scope.minDate = $scope.minDate ? null : new Date(); }; $scope.toggleMin(); @@ -31,10 +26,11 @@ var DatepickerDemoCtrl = function ($scope) { }; $scope.dateOptions = { - 'year-format': '\'yy\'', - 'starting-day': 1 + formatYear: 'yy', + startingDay: 1 }; + $scope.initDate = new Date('2016-15-20'); $scope.formats = ['dd-MMMM-yyyy', 'yyyy/MM/dd', 'shortDate']; $scope.format = $scope.formats[0]; }; diff --git a/src/datepicker/docs/readme.md b/src/datepicker/docs/readme.md index 83208dae1a..0e6886afca 100644 --- a/src/datepicker/docs/readme.md +++ b/src/datepicker/docs/readme.md @@ -13,19 +13,15 @@ All settings can be provided as attributes in the `` or globally con : The date object. - * `show-weeks` - _(Defaults: true)_ : - Whether to display week numbers. + * `datepicker-mode` + _(Defaults: 'day')_ : + Current mode of the datepicker _(day|month|year)_. Can be used to initialize datepicker to specific mode. - * `starting-day` - _(Defaults: 0)_ : - Starting day of the week from 0-6 (0=Sunday, ..., 6=Saturday). - - * `min` + * `min-date` _(Default: null)_ : Defines the minimum available date. - * `max` + * `max-date` _(Default: null)_ : Defines the maximum available date. @@ -33,34 +29,54 @@ All settings can be provided as attributes in the `` or globally con _(Default: null)_ : An optional expression to disable visible options based on passing date and current mode _(day|month|year)_. - * `day-format` + * `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). + + * `init-date` + : + The initial date view when no model value is not specified. + + * `min-mode` + _(Defaults: 'day')_ : + Set a lower limit for mode. + + * `max-mode` + _(Defaults: 'year')_ : + Set an upper limit for mode. + + * `format-day` _(Default: 'dd')_ : Format of day in month. - * `month-format` + * `format-month` _(Default: 'MMMM')_ : Format of month in year. - * `year-format` + * `format-year` _(Default: 'yyyy')_ : Format of year in year range. - * `year-range` - _(Default: 20)_ : - Number of years displayed in year selection. - - * `day-header-format` + * `format-day-header` _(Default: 'EEE')_ : Format of day in week header. - * `day-title-format` + * `format-day-title-` _(Default: 'MMMM yyyy')_ : Format of title when selecting day. - * `month-title-format` + * `format-month-title` _(Default: 'yyyy')_ : Format of title when selecting month. + * `year-range` + _(Default: 20)_ : + Number of years displayed in year selection. + ### Popup Settings ### @@ -79,10 +95,6 @@ Specific settings for the `datepicker-popup`, that can globally configured throu _(Default: 'Today')_ : The text to display for the current day button. - * `toggle-weeks-text` - _(Default: 'Weeks')_ : - The text to display for the toggling week numbers button. - * `clear-text` _(Default: 'Clear')_ : The text to display for the clear button. diff --git a/src/datepicker/test/datepicker.spec.js b/src/datepicker/test/datepicker.spec.js index ec679e1c85..1bafc4d336 100644 --- a/src/datepicker/test/datepicker.spec.js +++ b/src/datepicker/test/datepicker.spec.js @@ -2,6 +2,9 @@ describe('datepicker directive', function () { var $rootScope, $compile, element; beforeEach(module('ui.bootstrap.datepicker')); beforeEach(module('template/datepicker/datepicker.html')); + beforeEach(module('template/datepicker/day.html')); + beforeEach(module('template/datepicker/month.html')); + beforeEach(module('template/datepicker/year.html')); beforeEach(module('template/datepicker/popup.html')); beforeEach(inject(function(_$compile_, _$rootScope_) { $compile = _$compile_; @@ -13,11 +16,8 @@ describe('datepicker directive', function () { return element.find('th').eq(1).find('button').first().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() { + element.find('th').eq(1).find('button').first().click(); } function clickPreviousButton(times) { @@ -27,12 +27,8 @@ 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() { @@ -40,9 +36,8 @@ describe('datepicker directive', function () { } function getLabels() { - var els = getLabelsRow().find('th'); - - var labels = []; + var els = getLabelsRow().find('th'), + labels = []; for (var i = 1, n = els.length; i < n; i++) { labels.push( els.eq(i).text() ); } @@ -50,21 +45,21 @@ describe('datepicker directive', function () { } 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() ); } 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++) { + for (var i = dayMode ? 1 : 0, n = cols.length; i < n; i++) { days.push( cols.eq(i).find('button').text() ); } rows.push(days); @@ -72,38 +67,19 @@ describe('datepicker directive', function () { return rows; } - function _getOptionEl(rowIndex, colIndex) { - return element.find('tbody').find('tr').eq(rowIndex).find('td').eq(colIndex + 1); + function clickOption( index ) { + getAllOptionsEl().eq(index).click(); } - function clickOption(rowIndex, colIndex) { - _getOptionEl(rowIndex, colIndex).find('button').click(); - } - - function isDisabledOption(rowIndex, colIndex) { - return _getOptionEl(rowIndex, colIndex).find('button').prop('disabled'); - } - - 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) ); - } - rows.push(cols); - } - return rows; + function getAllOptionsEl( dayMode ) { + return element.find('tbody').find('button'); } - 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 ); + }); } describe('', function () { @@ -112,9 +88,8 @@ describe('datepicker directive', function () { $rootScope.$digest(); }); - it('is a `` element', function() { - expect(element.prop('tagName')).toBe('TABLE'); - expect(element.find('thead').find('tr').length).toBe(2); + it('is has a `
` element', function() { + expect(element.find('table').length).toBe(1); }); it('shows the correct title', function() { @@ -127,7 +102,7 @@ describe('datepicker directive', function () { }); it('renders the calendar days correctly', function() { - expect(getOptions()).toEqual([ + 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'], @@ -145,7 +120,7 @@ describe('datepicker directive', function () { }); it('has `selected` only the correct day', function() { - expectSelectedElement( 4, 4 ); + expectSelectedElement( 32 ); }); it('has no `selected` day when model is cleared', function() { @@ -153,7 +128,7 @@ describe('datepicker directive', function () { $rootScope.$digest(); expect($rootScope.date).toBe(null); - expectSelectedElement( null, null ); + expectSelectedElement( null ); }); it('does not change current view when model is cleared', function() { @@ -165,16 +140,14 @@ describe('datepicker directive', function () { }); 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('text-muted')).toBe( ((i === 0 && j < 3) || (i === 4 && j > 4)) ); - } - } + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function( button, index ) { + expect(angular.element(button).find('span').hasClass('text-muted')).toBe( index < 3 || index > 32 ); + }); }); it('updates the model when a day is clicked', function() { - clickOption(2, 3); + clickOption( 17 ); expect($rootScope.date).toEqual(new Date('September 15, 2010 15:30:00')); }); @@ -183,7 +156,7 @@ describe('datepicker directive', function () { expect(getTitle()).toBe('August 2010'); expect(getLabels()).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); - expect(getOptions()).toEqual([ + 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'], @@ -198,7 +171,7 @@ describe('datepicker directive', function () { clickPreviousButton(); expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); - clickOption(2, 3); + clickOption( 17 ); expect($rootScope.date).toEqual(new Date('August 18, 2010 15:30:00')); }); @@ -207,7 +180,7 @@ describe('datepicker directive', function () { expect(getTitle()).toBe('October 2010'); expect(getLabels()).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); - expect(getOptions()).toEqual([ + 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'], @@ -216,23 +189,23 @@ describe('datepicker directive', function () { ['31', '01', '02', '03', '04', '05', '06'] ]); - expectSelectedElement( 0, 4 ); + expectSelectedElement( 4 ); }); 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')); - clickOption(2, 3); + clickOption( 17 ); expect($rootScope.date).toEqual(new Date('October 13, 2010 15:30:00')); }); it('updates the calendar when a day of another month is selected', function() { - clickOption(4, 5); + clickOption( 33 ); 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([ + 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'], @@ -241,13 +214,23 @@ describe('datepicker directive', function () { ['31', '01', '02', '03', '04', '05', '06'] ]); - expectSelectedElement( 0, 5 ); + expectSelectedElement( 5 ); + }); + + // 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'); }); describe('when `model` changes', function () { function testCalendar() { expect(getTitle()).toBe('November 2005'); - expect(getOptions()).toEqual([ + 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'], @@ -255,7 +238,7 @@ describe('datepicker directive', function () { ['27', '28', '29', '30', '01', '02', '03'] ]); - expectSelectedElement( 1, 1 ); + expectSelectedElement( 8 ); } describe('to a Date object', function() { @@ -316,7 +299,6 @@ describe('datepicker directive', function () { }); it('shows months as options', function() { - expect(getLabels()).toEqual([]); expect(getOptions()).toEqual([ ['January', 'February', 'March'], ['April', 'May', 'June'], @@ -330,14 +312,13 @@ describe('datepicker directive', function () { }); it('has `selected` only the correct month', function() { - expectSelectedElement( 2, 2 ); + expectSelectedElement( 8 ); }); it('moves to the previous year when `previous` button is clicked', function() { clickPreviousButton(); expect(getTitle()).toBe('2009'); - expect(getLabels()).toEqual([]); expect(getOptions()).toEqual([ ['January', 'February', 'March'], ['April', 'May', 'June'], @@ -345,14 +326,13 @@ describe('datepicker directive', function () { ['October', 'November', 'December'] ]); - expectSelectedElement( null, null ); + expectSelectedElement( null ); }); it('moves to the next year when `next` button is clicked', function() { clickNextButton(); expect(getTitle()).toBe('2011'); - expect(getLabels()).toEqual([]); expect(getOptions()).toEqual([ ['January', 'February', 'March'], ['April', 'May', 'June'], @@ -360,17 +340,17 @@ describe('datepicker directive', function () { ['October', 'November', 'December'] ]); - expectSelectedElement( null, null ); + expectSelectedElement( null ); }); it('renders correctly when a month is clicked', function() { clickPreviousButton(5); expect(getTitle()).toBe('2005'); - clickOption(3, 1); + clickOption( 10 ); expect($rootScope.date).toEqual(new Date('September 30, 2010 15:30:00')); expect(getTitle()).toBe('November 2005'); - expect(getOptions()).toEqual([ + 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'], @@ -378,14 +358,15 @@ describe('datepicker directive', function () { ['27', '28', '29', '30', '01', '02', '03'] ]); - clickOption(2, 3); + clickOption( 17 ); expect($rootScope.date).toEqual(new Date('November 16, 2005 15:30:00')); }); }); describe('year selection mode', function () { beforeEach(function() { - clickTitleButton(2); + clickTitleButton(); + clickTitleButton(); }); it('shows the year range as title', function() { @@ -393,7 +374,6 @@ describe('datepicker directive', function () { }); it('shows years as options', function() { - expect(getLabels()).toEqual([]); expect(getOptions()).toEqual([ ['2001', '2002', '2003', '2004', '2005'], ['2006', '2007', '2008', '2009', '2010'], @@ -407,28 +387,26 @@ describe('datepicker directive', function () { }); it('has `selected` only the selected year', function() { - expectSelectedElement( 1, 4 ); + expectSelectedElement( 9 ); }); 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 ); + expectSelectedElement( null ); }); it('moves to the next year set when `next` button is clicked', function() { clickNextButton(); expect(getTitle()).toBe('2021 - 2040'); - expect(getLabels()).toEqual([]); expect(getOptions()).toEqual([ ['2021', '2022', '2023', '2024', '2025'], ['2026', '2027', '2028', '2029', '2030'], @@ -436,7 +414,7 @@ describe('datepicker directive', function () { ['2036', '2037', '2038', '2039', '2040'] ]); - expectSelectedElement( null, null ); + expectSelectedElement( null ); }); }); @@ -454,7 +432,7 @@ describe('datepicker directive', function () { }); it('renders the calendar days correctly', function() { - expect(getOptions()).toEqual([ + 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'], @@ -480,49 +458,34 @@ describe('datepicker directive', function () { }); it('hides week numbers based on variable', function() { - expect(weekHeader.text()).toEqual('#'); - expect(weekHeader).toBeHidden(); - expect(weekElement).toBeHidden(); - }); - - it('toggles week numbers', function() { - $rootScope.showWeeks = true; - $rootScope.$digest(); - expect(weekHeader.text()).toEqual('#'); - expect(weekHeader).not.toBeHidden(); - expect(weekElement).not.toBeHidden(); - - $rootScope.showWeeks = false; - $rootScope.$digest(); - expect(weekHeader.text()).toEqual('#'); + expect(weekHeader.text()).toEqual(''); expect(weekHeader).toBeHidden(); expect(weekElement).toBeHidden(); }); }); - describe('min attribute', function () { + describe('`min-date` attribute', function () { beforeEach(function() { $rootScope.mindate = new Date('September 12, 2010'); - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); }); 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) ); - } - } + 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.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) ); - } - } + + 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() { @@ -535,49 +498,44 @@ describe('datepicker directive', function () { 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 ); - } - } + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function( button, index ) { + expect(angular.element(button).prop('disabled')).toBe( true ); + }); }); 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 ); - } - } + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function( button, index ) { + expect(angular.element(button).prop('disabled')).toBe( false ); + }); }); 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)) ); - } - } + 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(); - for (var i = 0; i < 4; i ++) { - for (var j = 0; j < 3; j ++) { - expect(isDisabledOption(i, j)).toBe( true ); - } - } + 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(); - for (var i = 0; i < 4; i ++) { - for (var j = 0; j < 3; j ++) { - expect(isDisabledOption(i, j)).toBe( false ); - } - } + 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() { @@ -586,38 +544,36 @@ describe('datepicker directive', function () { $rootScope.$digest(); clickTitleButton(); - for (var i = 0; i < 4; i ++) { - for (var j = 0; j < 3; j ++) { - expect(isDisabledOption(i, j)).toBe( false ); - } - } + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function( button, index ) { + expect(angular.element(button).prop('disabled')).toBe( false ); + }); }); }); - describe('max attribute', function () { + describe('`max-date` attribute', function () { beforeEach(function() { $rootScope.maxdate = new Date('September 25, 2010'); - element = $compile('')($rootScope); + element = $compile('')($rootScope); $rootScope.$digest(); }); 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) ); - } - } + 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.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) ); - } - } + + 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() { @@ -629,59 +585,53 @@ describe('datepicker directive', function () { 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 ); - } - } + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function( button, index ) { + expect(angular.element(button).prop('disabled')).toBe( false ); + }); }); 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 ); - } - } + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function( button, index ) { + expect(angular.element(button).prop('disabled')).toBe( true ); + }); }); 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)) ); - } - } + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function( button, index ) { + expect(angular.element(button).prop('disabled')).toBe( index > 8 ); + }); }); 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 ); - } - } + 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(); - for (var i = 0; i < 4; i ++) { - for (var j = 0; j < 3; j ++) { - expect(isDisabledOption(i, j)).toBe( true ); - } - } + 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.maxdate = null; $rootScope.$digest(); - for (var i = 0; i < 5; i ++) { - for (var j = 0; j < 7; j ++) { - expect(isDisabledOption(i, j)).toBe( false ); - } - } + var buttons = getAllOptionsEl(); + angular.forEach(buttons, function( button, index ) { + expect(angular.element(button).prop('disabled')).toBe( false ); + }); }); }); @@ -710,9 +660,17 @@ describe('datepicker directive', function () { }); }); - describe('formatting attributes', function () { + describe('formatting', function () { beforeEach(function() { - element = $compile('')($rootScope); + $rootScope.dayTitle = 'MMMM, yy'; + element = $compile('')($rootScope); $rootScope.$digest(); }); @@ -733,7 +691,8 @@ describe('datepicker directive', function () { }); it('changes the title, year format & range in `year` mode', function() { - clickTitleButton(2); + clickTitleButton(); + clickTitleButton(); expect(getTitle()).toBe('01 - 10'); expect(getOptions()).toEqual([ @@ -747,7 +706,7 @@ describe('datepicker directive', function () { }); it('changes the day format', function() { - expect(getOptions()).toEqual([ + 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'], @@ -761,15 +720,15 @@ describe('datepicker directive', function () { var originalConfig = {}; beforeEach(inject(function(datepickerConfig) { angular.extend(originalConfig, datepickerConfig); - datepickerConfig.startingDay = 6; + datepickerConfig.formatDay = 'd'; + datepickerConfig.formatMonth = 'MMM'; + datepickerConfig.formatYear = 'yy'; + datepickerConfig.formatDayHeader = 'EEEE'; + datepickerConfig.formatDayTitle = 'MMM, yy'; + datepickerConfig.formatMonthTitle = 'yy'; 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'; + datepickerConfig.startingDay = 6; element = $compile('')($rootScope); $rootScope.$digest(); @@ -780,7 +739,7 @@ describe('datepicker directive', function () { })); it('changes the title format in `day` mode', function() { - expect(getTitle()).toBe('September, 10'); + expect(getTitle()).toBe('Sep, 10'); }); it('changes the title & months format in `month` mode', function() { @@ -796,7 +755,8 @@ describe('datepicker directive', function () { }); it('changes the title, year format & range in `year` mode', function() { - clickTitleButton(2); + clickTitleButton(); + clickTitleButton(); expect(getTitle()).toBe('01 - 10'); expect(getOptions()).toEqual([ @@ -807,7 +767,7 @@ describe('datepicker directive', function () { it('changes the `starting-day` & day headers & format', function() { expect(getLabels()).toEqual(['Saturday', 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']); - expect(getOptions()).toEqual([ + expect(getOptions(true)).toEqual([ ['28', '29', '30', '31', '1', '2', '3'], ['4', '5', '6', '7', '8', '9', '10'], ['11', '12', '13', '14', '15', '16', '17'], @@ -826,132 +786,6 @@ describe('datepicker directive', function () { }); - describe('controller', function () { - var ctrl, $attrs; - beforeEach(inject(function($controller) { - $rootScope.dateDisabled = null; - $attrs = {}; - ctrl = $controller('DatepickerController', { $scope: $rootScope, $attrs: $attrs }); - })); - - describe('modes', function() { - var currentMode; - - it('to be an array', function() { - expect(ctrl.modes.length).toBe(3); - }); - - describe('`day`', function() { - beforeEach(inject(function() { - currentMode = ctrl.modes[0]; - })); - - it('has the appropriate name', function() { - expect(currentMode.name).toBe('day'); - }); - - 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('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); - }); - }); - - describe('`month`', function() { - beforeEach(inject(function() { - currentMode = ctrl.modes[1]; - })); - - it('has the appropriate name', function() { - expect(currentMode.name).toBe('month'); - }); - - 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('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); - }); - }); - - describe('`year`', function() { - beforeEach(inject(function() { - currentMode = ctrl.modes[2]; - })); - - it('has the appropriate name', function() { - expect(currentMode.name).toBe('year'); - }); - - 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('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); - }); - }); - }); - - describe('`isDisabled` function', function() { - var date = new Date('September 30, 2010 15:30:00'); - - it('to return false if no limit is set', function() { - expect(ctrl.isDisabled(date, 0)).toBeFalsy(); - }); - - 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(); - - ctrl.minDate = new Date('September 1, 2010'); - expect(ctrl.isDisabled(date, 0)).toBeFalsy(); - }); - - it('to handle correctly the `max` date', function() { - ctrl.maxDate = new Date('October 1, 2010'); - expect(ctrl.isDisabled(date, 0)).toBeFalsy(); - - ctrl.maxDate = new Date('September 1, 2010'); - expect(ctrl.isDisabled(date, 0)).toBeTruthy(); - expect(ctrl.isDisabled(date)).toBeTruthy(); - }); - - it('to handle correctly the scope `dateDisabled` expression', function() { - $rootScope.dateDisabled = function() { - return false; - }; - $rootScope.$digest(); - expect(ctrl.isDisabled(date, 0)).toBeFalsy(); - - $rootScope.dateDisabled = function() { - return true; - }; - $rootScope.$digest(); - expect(ctrl.isDisabled(date, 0)).toBeTruthy(); - }); - }); - }); - describe('as popup', function () { var inputEl, dropdownEl, changeInputValueTo, $document; @@ -992,7 +826,7 @@ describe('datepicker directive', function () { 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([ + 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'], @@ -1002,14 +836,14 @@ describe('datepicker directive', function () { }); it('updates the input when a day is clicked', function() { - clickOption(2, 3); + 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(2, 3); + clickOption(17); expect(inputEl).toHaveClass('ng-dirty'); }); @@ -1023,7 +857,7 @@ describe('datepicker directive', function () { inputEl.focus(); expect(dropdownEl.css('display')).not.toBe('none'); - clickOption(2, 3); + clickOption(17); expect(dropdownEl.css('display')).toBe('none'); }); @@ -1034,7 +868,7 @@ describe('datepicker directive', function () { expect($rootScope.date.getMonth()).toEqual(2); expect($rootScope.date.getDate()).toEqual(5); - expect(getOptions()).toEqual([ + 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'], @@ -1042,10 +876,13 @@ describe('datepicker directive', function () { ['23', '24', '25', '26', '27', '28', '29'], ['30', '31', '01', '02', '03', '04', '05'] ]); - expectSelectedElement( 1, 3 ); + expectSelectedElement( 10 ); }); it('closes when click outside of calendar', function() { + inputEl.focus(); + expect(dropdownEl).not.toBeHidden(); + $document.find('body').click(); expect(dropdownEl.css('display')).toBe('none'); }); @@ -1059,6 +896,16 @@ describe('datepicker directive', function () { 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('attribute `datepickerOptions`', function () { @@ -1076,7 +923,7 @@ describe('datepicker directive', function () { }); it('hides week numbers based on variable', function() { - expect(weekHeader.text()).toEqual('#'); + expect(weekHeader.text()).toEqual(''); expect(weekHeader).toBeHidden(); expect(weekElement).toBeHidden(); }); @@ -1118,7 +965,7 @@ describe('datepicker directive', function () { }); it('updates the input when a day is clicked', function() { - clickOption(2, 3); + clickOption(17); expect(inputEl.val()).toBe('15-September-2010'); expect($rootScope.date).toEqual(new Date('September 15, 2010 15:30:00')); }); @@ -1143,7 +990,7 @@ describe('datepicker directive', function () { }); it('updates the input when a day is clicked', function() { - clickOption(2, 3); + clickOption(17); expect(inputEl.val()).toBe('15-September-2010'); expect($rootScope.date).toEqual(new Date('September 15, 2010 15:30:00')); }); @@ -1169,8 +1016,8 @@ describe('datepicker directive', function () { assignElements(wrapElement); })); - it('dpes not close the dropdown when a day is clicked', function() { - clickOption(2, 3); + it('does not close the dropdown when a day is clicked', function() { + clickOption(17); expect(dropdownEl.css('display')).not.toBe('none'); }); }); @@ -1191,21 +1038,20 @@ describe('datepicker directive', function () { assignButtonBar(); })); - it('should be visible', function() { - expect(buttonBarElement.css('display')).not.toBe('none'); + it('should exist', function() { + expect(dropdownEl.find('li').length).toBe(2); }); it('should have four buttons', function() { - expect(buttons.length).toBe(4); + expect(buttons.length).toBe(3); expect(buttons.eq(0).text()).toBe('Today'); - expect(buttons.eq(1).text()).toBe('Weeks'); - expect(buttons.eq(2).text()).toBe('Clear'); - expect(buttons.eq(3).text()).toBe('Done'); + expect(buttons.eq(1).text()).toBe('Clear'); + expect(buttons.eq(2).text()).toBe('Done'); }); it('should have a button to clear value', function() { - buttons.eq(2).click(); + buttons.eq(1).click(); expect($rootScope.date).toBe(null); }); @@ -1213,31 +1059,31 @@ describe('datepicker directive', function () { inputEl.focus(); expect(dropdownEl.css('display')).not.toBe('none'); - buttons.eq(3).click(); + buttons.eq(2).click(); expect(dropdownEl.css('display')).toBe('none'); }); }); describe('customization', function() { - beforeEach(inject(function() { - $rootScope.showBar = false; + it('should change text from attributes', function() { $rootScope.clearText = 'Null it!'; $rootScope.close = 'Close'; - var wrapElement = $compile('
')($rootScope); + var wrapElement = $compile('
')($rootScope); $rootScope.$digest(); assignElements(wrapElement); assignButtonBar(); - })); - it('should change text from attributes', function() { expect(buttons.eq(0).text()).toBe('Now'); - expect(buttons.eq(1).text()).toBe('T.W.'); - expect(buttons.eq(2).text()).toBe('Null it!'); - expect(buttons.eq(3).text()).toBe('CloseME'); + expect(buttons.eq(1).text()).toBe('Null it!'); + expect(buttons.eq(2).text()).toBe('CloseME'); }); - it('should hide bar', function() { - expect(buttonBarElement).toBeHidden(); + it('should remove bar', function() { + $rootScope.showBar = false; + var wrapElement = $compile('
')($rootScope); + $rootScope.$digest(); + assignElements(wrapElement); + expect(dropdownEl.find('li').length).toBe(1); }); }); @@ -1255,18 +1101,13 @@ describe('datepicker directive', function () { expect($rootScope.changeHandler).toHaveBeenCalled(); }); - it('should not be called when `weeks` is clicked', function() { - buttons.eq(1).click(); - expect($rootScope.changeHandler).not.toHaveBeenCalled(); - }); - it('should be called when `clear` is clicked', function() { - buttons.eq(2).click(); + buttons.eq(1).click(); expect($rootScope.changeHandler).toHaveBeenCalled(); }); it('should not be called when `close` is clicked', function() { - buttons.eq(3).click(); + buttons.eq(2).click(); expect($rootScope.changeHandler).not.toHaveBeenCalled(); }); }); @@ -1304,7 +1145,7 @@ describe('datepicker directive', function () { }); it('should be called when a day is clicked', function() { - clickOption(2, 3); + clickOption(17); expect($rootScope.changeHandler).toHaveBeenCalled(); }); @@ -1372,16 +1213,15 @@ describe('datepicker directive', function () { }); }); - describe('datepicker directive with empty initial state', function () { + describe('with empty initial state', function () { beforeEach(inject(function() { $rootScope.date = null; element = $compile('')($rootScope); $rootScope.$digest(); })); - it('is a `
` element', function() { - expect(element.prop('tagName')).toBe('TABLE'); - expect(element.find('thead').find('tr').length).toBe(2); + it('is has a `
` element', function() { + expect(element.find('table').length).toBe(1); }); it('is shows rows with days', function() { @@ -1394,8 +1234,76 @@ describe('datepicker directive', function () { $rootScope.date = null; $rootScope.$digest(); - clickOption(2, 0); + clickOption(14); expect($rootScope.date).toEqual(new Date('August 11, 2013 00:00:00')); }); }); + + describe('`init-date`', function () { + beforeEach(inject(function() { + $rootScope.date = null; + $rootScope.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('`datepicker-mode`', function () { + beforeEach(inject(function() { + $rootScope.date = new Date('August 11, 2013'); + $rootScope.mode = 'month'; + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('shows the correct title', function() { + expect(getTitle()).toBe('2013'); + }); + + it('updates binding', function() { + clickTitleButton(); + expect($rootScope.mode).toBe('year'); + }); + }); + + describe('`min-mode`', function () { + beforeEach(inject(function() { + $rootScope.date = new Date('August 11, 2013'); + $rootScope.mode = 'month'; + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('loops between allowed modes', function() { + expect(getTitle()).toBe('2013'); + clickTitleButton(); + expect(getTitle()).toBe('2001 - 2020'); + clickTitleButton(); + expect(getTitle()).toBe('2013'); + }); + }); + + describe('`max-mode`', function () { + beforeEach(inject(function() { + $rootScope.date = new Date('August 11, 2013'); + element = $compile('')($rootScope); + $rootScope.$digest(); + })); + + it('loops between allowed modes', function() { + expect(getTitle()).toBe('August 2013'); + clickTitleButton(); + expect(getTitle()).toBe('2013'); + clickTitleButton(); + expect(getTitle()).toBe('August 2013'); + }); + }); }); diff --git a/template/datepicker/datepicker.html b/template/datepicker/datepicker.html index 76cfb6b39e..451f2bc28a 100644 --- a/template/datepicker/datepicker.html +++ b/template/datepicker/datepicker.html @@ -1,21 +1,5 @@ -
- - - - - - - - - - - - - - - - - -
#{{label}}
{{ getWeekNumber(row) }} - -
+
+ + + +
\ No newline at end of file diff --git a/template/datepicker/day.html b/template/datepicker/day.html new file mode 100644 index 0000000000..402b05d13a --- /dev/null +++ b/template/datepicker/day.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + +
{{label}}
{{ weekNumbers[$index] }} + +
diff --git a/template/datepicker/month.html b/template/datepicker/month.html new file mode 100644 index 0000000000..c126f3350f --- /dev/null +++ b/template/datepicker/month.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + +
+ +
diff --git a/template/datepicker/popup.html b/template/datepicker/popup.html index 84176b7526..f2ea1df7a6 100644 --- a/template/datepicker/popup.html +++ b/template/datepicker/popup.html @@ -1,11 +1,10 @@ diff --git a/template/datepicker/year.html b/template/datepicker/year.html new file mode 100644 index 0000000000..e45a0ce233 --- /dev/null +++ b/template/datepicker/year.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + +
+ +