From 2423f6d4c05cb0eb3fd2104dedbeb0e3740f7f68 Mon Sep 17 00:00:00 2001 From: Tasos Bekos Date: Wed, 12 Feb 2014 23:12:36 +0100 Subject: [PATCH] feat(datepicker): make widget accessible * keyboard navigation * WAI-ARIA roles * popup will close on escape on input or calendar * handle focus when closing popup Closes #1922 BREAKING CHANGES: popup calendar does not open on input focus --- src/datepicker/datepicker.js | 283 ++++++++++++++----- src/datepicker/docs/readme.md | 21 +- src/datepicker/test/datepicker.spec.js | 361 +++++++++++++++++++++++-- template/datepicker/datepicker.html | 8 +- template/datepicker/day.html | 16 +- template/datepicker/month.html | 12 +- template/datepicker/popup.html | 4 +- template/datepicker/year.html | 12 +- 8 files changed, 590 insertions(+), 127 deletions(-) diff --git a/src/datepicker/datepicker.js b/src/datepicker/datepicker.js index 4dae6719e6..89498b85a5 100644 --- a/src/datepicker/datepicker.js +++ b/src/datepicker/datepicker.js @@ -17,10 +17,13 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) maxDate: null }) -.controller('DatepickerController', ['$scope', '$attrs', '$parse', '$interpolate', '$log', 'dateFilter', 'datepickerConfig', function($scope, $attrs, $parse, $interpolate, $log, dateFilter, datepickerConfig) { +.controller('DatepickerController', ['$scope', '$attrs', '$parse', '$interpolate', '$timeout', '$log', 'dateFilter', 'datepickerConfig', function($scope, $attrs, $parse, $interpolate, $timeout, $log, dateFilter, datepickerConfig) { var self = this, ngModelCtrl = { $setViewValue: angular.noop }; // nullModelCtrl; + // Modes chain + this.modes = ['day', 'month', 'year']; + // Configuration attributes angular.forEach(['formatDay', 'formatMonth', 'formatYear', 'formatDayHeader', 'formatDayTitle', 'formatMonthTitle', 'minMode', 'maxMode', 'showWeeks', 'startingDay', 'yearRange'], function( key, index ) { @@ -40,7 +43,16 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) }); $scope.datepickerMode = $scope.datepickerMode || datepickerConfig.datepickerMode; - this.currentCalendarDate = angular.isDefined($attrs.initDate) ? $scope.$parent.$eval($attrs.initDate) : new Date(); + $scope.uniqueId = 'datepicker-' + $scope.$id + '-' + Math.floor(Math.random() * 10000); + this.activeDate = angular.isDefined($attrs.initDate) ? $scope.$parent.$eval($attrs.initDate) : new Date(); + + $scope.isActive = function(dateObject) { + if (self.compare(dateObject.date, self.activeDate) === 0) { + $scope.activeDateId = dateObject.uid; + return true; + } + return false; + }; this.init = function( ngModelCtrl_ ) { ngModelCtrl = ngModelCtrl_; @@ -56,7 +68,7 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) isValid = !isNaN(date); if ( isValid ) { - this.currentCalendarDate = date; + this.activeDate = 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.'); } @@ -66,11 +78,11 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) }; this.refreshView = function() { - if ( this.mode ) { + if ( this.element ) { this._refreshView(); var date = ngModelCtrl.$modelValue ? new Date(ngModelCtrl.$modelValue) : null; - ngModelCtrl.$setValidity('date-disabled', !date || (this.mode && !this.isDisabled(date))); + ngModelCtrl.$setValidity('date-disabled', !date || (this.element && !this.isDisabled(date))); } }; @@ -86,7 +98,7 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) }; 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}))); + return ((this.minDate && this.compare(date, this.minDate) < 0) || (this.maxDate && this.compare(date, this.maxDate) > 0) || ($attrs.dateDisabled && $scope.dateDisabled({date: date, mode: $scope.datepickerMode}))); }; // Split array into smaller arrays @@ -105,23 +117,87 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) ngModelCtrl.$setViewValue( dt ); ngModelCtrl.$render(); } else { - self.currentCalendarDate = date; - $scope.datepickerMode = self.mode.previous; + self.activeDate = date; + $scope.datepickerMode = self.modes[ self.modes.indexOf( $scope.datepickerMode ) - 1 ]; } }; $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); + 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(); }; - $scope.toggleMode = function() { - $scope.datepickerMode = $scope.datepickerMode === self.maxMode ? self.minMode : self.mode.next; + $scope.toggleMode = function( direction ) { + direction = direction || 1; + + if (($scope.datepickerMode === self.maxMode && direction === 1) || ($scope.datepickerMode === self.minMode && direction === -1)) { + return; + } + + $scope.datepickerMode = self.modes[ self.modes.indexOf( $scope.datepickerMode ) + direction ]; + }; + + // 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() { + $timeout(function() { + self.element[0].focus(); + }, 0 , false); + }; + + // Listen for focus requests from popup directive + $scope.$on('datepicker.focus', focusElement); + + $scope.keydown = function( evt ) { + var key = $scope.keys[evt.which]; + + if ( !key || evt.shiftKey || evt.altKey ) { + return; + } + + evt.preventDefault(); + evt.stopPropagation(); + + if (key === 'enter' || key === 'space') { + if ( self.isDisabled(self.activeDate)) { + return; // do nothing + } + $scope.select(self.activeDate); + focusElement(); + } else if (evt.ctrlKey && (key === 'up' || key === 'down')) { + $scope.toggleMode(key === 'up' ? 1 : -1); + focusElement(); + } else { + self.handleKeyDown(key, evt); + self.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 ); + } + } + }; +}) + .directive('daypicker', ['dateFilter', function (dateFilter) { return { restrict: 'EA', @@ -131,13 +207,12 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) link: function(scope, element, attrs, ctrl) { scope.showWeeks = ctrl.showWeeks; - ctrl.mode = { - step: { months: 1 }, - next: 'month' - }; + ctrl.step = { months: 1 }; + ctrl.element = element; + var DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; function getDaysInMonth( year, month ) { - return new Date(year, month, 0).getDate(); + return ((month === 1) && (year % 4 === 0) && ((year % 100 !== 0) || (year % 400 === 0))) ? 29 : DAYS_IN_MONTH[month]; } function getDates(startDate, n) { @@ -151,8 +226,8 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) } ctrl._refreshView = function() { - var year = ctrl.currentCalendarDate.getFullYear(), - month = ctrl.currentCalendarDate.getMonth(), + var year = ctrl.activeDate.getFullYear(), + month = ctrl.activeDate.getMonth(), firstDayOfMonth = new Date(year, month, 1), difference = ctrl.startingDay - firstDayOfMonth.getDay(), numDisplayedFromPreviousMonth = (difference > 0) ? 7 - difference : - difference, @@ -162,22 +237,26 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) firstDate.setDate( - numDisplayedFromPreviousMonth + 1 ); numDates += numDisplayedFromPreviousMonth; // Previous } - numDates += getDaysInMonth(year, month + 1); // Current + numDates += getDaysInMonth(year, month); // Current numDates += (7 - numDates % 7) % 7; // Next 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 + secondary: days[i].getMonth() !== month, + uid: scope.uniqueId + '-' + i }); } scope.labels = new Array(7); for (var j = 0; j < 7; j++) { - scope.labels[j] = dateFilter(days[j].date, ctrl.formatDayHeader); + scope.labels[j] = { + abbr: dateFilter(days[j].date, ctrl.formatDayHeader), + full: dateFilter(days[j].date, 'EEEE') + }; } - scope.title = dateFilter(ctrl.currentCalendarDate, ctrl.formatDayTitle); + scope.title = dateFilter(ctrl.activeDate, ctrl.formatDayTitle); scope.rows = ctrl.split(days, 7); if ( scope.showWeeks ) { @@ -201,6 +280,29 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) return Math.floor(Math.round((time - checkDate) / 86400000) / 7) + 1; } + ctrl.handleKeyDown = function( key, evt ) { + var date = ctrl.activeDate.getDate(); + + if (key === 'left') { + date = date - 1; // up + } else if (key === 'up') { + date = date - 7; // down + } else if (key === 'right') { + date = date + 1; // down + } else if (key === 'down') { + date = date + 7; + } else if (key === 'pageup' || key === 'pagedown') { + var month = ctrl.activeDate.getMonth() + (key === 'pageup' ? - 1 : 1); + ctrl.activeDate.setMonth(month, 1); + date = Math.min(getDaysInMonth(ctrl.activeDate.getFullYear(), ctrl.activeDate.getMonth()), date); + } else if (key === 'home') { + date = 1; + } else if (key === 'end') { + date = getDaysInMonth(ctrl.activeDate.getFullYear(), ctrl.activeDate.getMonth()); + } + ctrl.activeDate.setDate(date); + }; + ctrl.refreshView(); } }; @@ -213,21 +315,20 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) templateUrl: 'template/datepicker/month.html', require: '^datepicker', link: function(scope, element, attrs, ctrl) { - ctrl.mode = { - step: { years: 1 }, - previous: 'day', - next: 'year' - }; + ctrl.step = { years: 1 }; + ctrl.element = element; ctrl._refreshView = function() { var months = new Array(12), - year = ctrl.currentCalendarDate.getFullYear(); + year = ctrl.activeDate.getFullYear(); for ( var i = 0; i < 12; i++ ) { - months[i] = ctrl.createDateObject(new Date(year, i, 1), ctrl.formatMonth); + months[i] = angular.extend(ctrl.createDateObject(new Date(year, i, 1), ctrl.formatMonth), { + uid: scope.uniqueId + '-' + i + }); } - scope.title = dateFilter(ctrl.currentCalendarDate, ctrl.formatMonthTitle); + scope.title = dateFilter(ctrl.activeDate, ctrl.formatMonthTitle); scope.rows = ctrl.split(months, 3); }; @@ -235,6 +336,28 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) return new Date( date1.getFullYear(), date1.getMonth() ) - new Date( date2.getFullYear(), date2.getMonth() ); }; + ctrl.handleKeyDown = function( key, evt ) { + var date = ctrl.activeDate.getMonth(); + + if (key === 'left') { + date = date - 1; // up + } else if (key === 'up') { + date = date - 3; // down + } else if (key === 'right') { + date = date + 1; // down + } else if (key === 'down') { + date = date + 3; + } else if (key === 'pageup' || key === 'pagedown') { + var year = ctrl.activeDate.getFullYear() + (key === 'pageup' ? - 1 : 1); + ctrl.activeDate.setFullYear(year); + } else if (key === 'home') { + date = 0; + } else if (key === 'end') { + date = 11; + } + ctrl.activeDate.setMonth(date); + }; + ctrl.refreshView(); } }; @@ -247,18 +370,22 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) templateUrl: 'template/datepicker/year.html', require: '^datepicker', link: function(scope, element, attrs, ctrl) { - ctrl.mode = { - step: { years: ctrl.yearRange }, - previous: 'month' - }; + var range = ctrl.yearRange; + + ctrl.step = { years: range }; + ctrl.element = element; + + function getStartingYear( year ) { + return parseInt((year - 1) / range, 10) * range + 1; + } ctrl._refreshView = function() { - var range = this.mode.step.years, - years = new Array(range), - start = parseInt((ctrl.currentCalendarDate.getFullYear() - 1) / range, 10) * range + 1; + var years = new Array(range); - for ( var i = 0; i < range; i++ ) { - years[i] = ctrl.createDateObject(new Date(start + i, 0, 1), ctrl.formatYear); + for ( var i = 0, start = getStartingYear(ctrl.activeDate.getFullYear()); i < range; i++ ) { + years[i] = angular.extend(ctrl.createDateObject(new Date(start + i, 0, 1), ctrl.formatYear), { + uid: scope.uniqueId + '-' + i + }); } scope.title = [years[0].label, years[range - 1].label].join(' - '); @@ -269,32 +396,32 @@ angular.module('ui.bootstrap.datepicker', ['ui.bootstrap.position']) return date1.getFullYear() - date2.getFullYear(); }; + ctrl.handleKeyDown = function( key, evt ) { + var date = ctrl.activeDate.getFullYear(); + + if (key === 'left') { + date = date - 1; // up + } else if (key === 'up') { + date = date - 5; // down + } else if (key === 'right') { + date = date + 1; // down + } else if (key === 'down') { + date = date + 5; + } else if (key === 'pageup' || key === 'pagedown') { + date += (key === 'pageup' ? - 1 : 1) * ctrl.step.years; + } else if (key === 'home') { + date = getStartingYear( ctrl.activeDate.getFullYear() ); + } else if (key === 'end') { + date = getStartingYear( ctrl.activeDate.getFullYear() ) + range - 1; + } + ctrl.activeDate.setFullYear(date); + }; + 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', { datepickerPopup: 'yyyy-MM-dd', currentText: 'Today', @@ -314,7 +441,8 @@ function ($compile, $parse, $document, $position, dateFilter, datepickerPopupCon isOpen: '=?', currentText: '@', clearText: '@', - closeText: '@' + closeText: '@', + dateDisabled: '&' }, link: function(scope, element, attrs, ngModel) { var dateFormat, @@ -360,7 +488,7 @@ function ($compile, $parse, $document, $position, dateFilter, datepickerPopupCon } }); if (attrs.dateDisabled) { - datepickerEl.attr('date-disabled', attrs.dateDisabled); + datepickerEl.attr('date-disabled', 'dateDisabled({ date: date, mode: mode })'); } // TODO: reverse from dateFilter string to Date object @@ -397,6 +525,7 @@ function ($compile, $parse, $document, $position, dateFilter, datepickerPopupCon if ( closeOnDateSelection ) { scope.isOpen = false; + element[0].focus(); } }; @@ -421,23 +550,30 @@ function ($compile, $parse, $document, $position, dateFilter, datepickerPopupCon } }; - var openCalendar = function() { - scope.$apply(function() { + var keydown = function(evt, noApply) { + scope.keydown(evt); + }; + element.bind('keydown', keydown); + + scope.keydown = function(evt) { + if (evt.which === 27) { + evt.preventDefault(); + evt.stopPropagation(); + scope.close(); + } else if (evt.which === 40 && !scope.isOpen) { scope.isOpen = true; - }); + } }; scope.$watch('isOpen', function(value) { if (value) { + scope.$broadcast('datepicker.focus'); scope.position = appendToBody ? $position.offset(element) : $position.position(element); scope.position.top = scope.position.top + element.prop('offsetHeight'); $document.bind('click', documentClickBind); - element.unbind('focus', openCalendar); - element[0].focus(); } else { $document.unbind('click', documentClickBind); - element.bind('focus', openCalendar); } }); @@ -454,6 +590,11 @@ function ($compile, $parse, $document, $position, dateFilter, datepickerPopupCon scope.dateSelection( date ); }; + scope.close = function() { + scope.isOpen = false; + element[0].focus(); + }; + var $popup = $compile(popupEl)(scope); if ( appendToBody ) { $document.find('body').append($popup); @@ -463,7 +604,7 @@ function ($compile, $parse, $document, $position, dateFilter, datepickerPopupCon scope.$on('$destroy', function() { $popup.remove(); - element.unbind('focus', openCalendar); + element.unbind('keydown', keydown); $document.unbind('click', documentClickBind); }); } diff --git a/src/datepicker/docs/readme.md b/src/datepicker/docs/readme.md index 0e6886afca..537b08c4f6 100644 --- a/src/datepicker/docs/readme.md +++ b/src/datepicker/docs/readme.md @@ -7,7 +7,7 @@ Everything is formatted using the [date filter](http://docs.angularjs.org/api/ng ### Datepicker Settings ### -All settings can be provided as attributes in the `` or globally configured through the `datepickerConfig`. +All settings can be provided as attributes in the `datepicker` or globally configured through the `datepickerConfig`. * `ng-model` : @@ -65,7 +65,7 @@ All settings can be provided as attributes in the `` or globally con _(Default: 'EEE')_ : Format of day in week header. - * `format-day-title-` + * `format-day-title` _(Default: 'MMMM yyyy')_ : Format of title when selecting day. @@ -110,3 +110,20 @@ Specific settings for the `datepicker-popup`, that can globally configured throu * `datepicker-append-to-body` _(Default: false)_: Append the datepicker popup element to `body`, rather than inserting after `datepicker-popup`. For global configuration, use `datepickerPopupConfig.appendToBody`. + +### Keyboard Support ### + +Depending on datepicker's current mode, the date may reffer either to day, month or year. Accordingly, the term view reffers 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. \ No newline at end of file diff --git a/src/datepicker/test/datepicker.spec.js b/src/datepicker/test/datepicker.spec.js index d8083c3ce3..cd4c5b8398 100644 --- a/src/datepicker/test/datepicker.spec.js +++ b/src/datepicker/test/datepicker.spec.js @@ -82,6 +82,28 @@ describe('datepicker directive', function () { }); } + 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); + } + describe('', function () { beforeEach(function() { element = $compile('')($rootScope); @@ -285,7 +307,7 @@ describe('datepicker directive', function () { }); }); - it('loops between different modes', function() { + it('does not loop between after max mode', function() { expect(getTitle()).toBe('September 2010'); clickTitleButton(); @@ -295,7 +317,7 @@ describe('datepicker directive', function () { expect(getTitle()).toBe('2001 - 2020'); clickTitleButton(); - expect(getTitle()).toBe('September 2010'); + expect(getTitle()).toBe('2001 - 2020'); }); describe('month selection mode', function () { @@ -427,6 +449,245 @@ describe('datepicker directive', function () { }); }); + describe('keyboard navigation', function() { + function getActiveLabel() { + return element.find('.active').eq(0).text(); + } + + 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'); + }); + }); + + 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')); + }); + }); + + 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('`aria-activedescendant`', function() { + function checkActivedescendant() { + var activeId = element.find('table').attr('aria-activedescendant'); + expect(element.find('#' + activeId + ' > button')).toHaveClass('active'); + } + + it('updates correctly', function() { + triggerKeyDown(element, 'left'); + checkActivedescendant(); + + triggerKeyDown(element, 'down'); + checkActivedescendant(); + + triggerKeyDown(element, 'up', true); + checkActivedescendant(); + + triggerKeyDown(element, 'up', true); + checkActivedescendant(); + }); + }); + + }); + }); describe('attribute `starting-day`', function () { @@ -816,7 +1077,7 @@ describe('datepicker directive', function () { }); describe('as popup', function () { - var inputEl, dropdownEl, changeInputValueTo, $document; + var inputEl, dropdownEl, $document, $sniffer; function assignElements(wrapElement) { inputEl = wrapElement.find('input'); @@ -824,31 +1085,43 @@ describe('datepicker directive', function () { element = dropdownEl.find('table'); } - describe('', function () { - beforeEach(inject(function(_$document_, $sniffer) { + function changeInputValueTo(el, value) { + el.val(value); + el.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); + $rootScope.$digest(); + } + + describe('initially', function () { + beforeEach(inject(function(_$document_, _$sniffer_) { $document = _$document_; + $rootScope.isopen = true; $rootScope.date = new Date('September 30, 2010 15:30:00'); var wrapElement = $compile('
')($rootScope); $rootScope.$digest(); assignElements(wrapElement); - - changeInputValueTo = function (el, value) { - el.val(value); - el.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); - $rootScope.$digest(); - }; })); + it('does not to display datepicker initially', function() { + expect(dropdownEl).toBeHidden(); + }); + it('to display the correct value in input', function() { expect(inputEl.val()).toBe('2010-09-30'); }); + }); - it('does not to display datepicker initially', function() { - expect(dropdownEl).toBeHidden(); - }); + describe('initially opened', 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('displays datepicker on input focus', function() { - inputEl.focus(); + it('datepicker is displayed', function() { expect(dropdownEl).not.toBeHidden(); }); @@ -883,7 +1156,6 @@ describe('datepicker directive', function () { }); it('closes the dropdown when a day is clicked', function() { - inputEl.focus(); expect(dropdownEl.css('display')).not.toBe('none'); clickOption(17); @@ -909,7 +1181,6 @@ describe('datepicker directive', function () { }); it('closes when click outside of calendar', function() { - inputEl.focus(); expect(dropdownEl).not.toBeHidden(); $document.find('body').click(); @@ -935,6 +1206,41 @@ describe('datepicker directive', function () { 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).not.toBeHidden(); + + dropdownEl.find('button').eq(0).focus(); + expect(document.activeElement.tagName).toBe('BUTTON'); + + triggerKeyDown(dropdownEl, 'esc'); + expect(dropdownEl).toBeHidden(); + expect(document.activeElement.tagName).toBe('INPUT'); + }); + + it('returns to the input when ESC key is pressed in the input and closes', function() { + expect(dropdownEl).not.toBeHidden(); + + dropdownEl.find('button').eq(0).focus(); + expect(document.activeElement.tagName).toBe('BUTTON'); + + triggerKeyDown(inputEl, 'esc'); + $rootScope.$digest(); + expect(dropdownEl).toBeHidden(); + expect(document.activeElement.tagName).toBe('INPUT'); + }); + }); }); describe('attribute `datepickerOptions`', function () { @@ -1061,13 +1367,15 @@ describe('datepicker directive', function () { describe('', function () { beforeEach(inject(function() { - var wrapElement = $compile('
')($rootScope); + $rootScope.isopen = true; + var wrapElement = $compile('
')($rootScope); $rootScope.$digest(); assignElements(wrapElement); assignButtonBar(); })); it('should exist', function() { + expect(dropdownEl).not.toBeHidden(); expect(dropdownEl.find('li').length).toBe(2); }); @@ -1112,11 +1420,8 @@ describe('datepicker directive', function () { }); it('should have a button to close calendar', function() { - inputEl.focus(); - expect(dropdownEl.css('display')).not.toBe('none'); - buttons.eq(2).click(); - expect(dropdownEl.css('display')).toBe('none'); + expect(dropdownEl).toBeHidden(); }); }); @@ -1338,12 +1643,12 @@ describe('datepicker directive', function () { $rootScope.$digest(); })); - it('loops between allowed modes', function() { + it('does not move below it', function() { + expect(getTitle()).toBe('2013'); + clickOption( 5 ); expect(getTitle()).toBe('2013'); clickTitleButton(); expect(getTitle()).toBe('2001 - 2020'); - clickTitleButton(); - expect(getTitle()).toBe('2013'); }); }); @@ -1354,12 +1659,12 @@ describe('datepicker directive', function () { $rootScope.$digest(); })); - it('loops between allowed modes', function() { + it('does not move above it', function() { expect(getTitle()).toBe('August 2013'); clickTitleButton(); expect(getTitle()).toBe('2013'); clickTitleButton(); - expect(getTitle()).toBe('August 2013'); + expect(getTitle()).toBe('2013'); }); }); }); diff --git a/template/datepicker/datepicker.html b/template/datepicker/datepicker.html index 451f2bc28a..1ecb3c50b4 100644 --- a/template/datepicker/datepicker.html +++ b/template/datepicker/datepicker.html @@ -1,5 +1,5 @@ -
- - - +
+ + +
\ No newline at end of file diff --git a/template/datepicker/day.html b/template/datepicker/day.html index d4c93fb364..ca212a391a 100644 --- a/template/datepicker/day.html +++ b/template/datepicker/day.html @@ -1,20 +1,20 @@ - +
- - - + + + - + - - + diff --git a/template/datepicker/month.html b/template/datepicker/month.html index eb139f9c07..539219004b 100644 --- a/template/datepicker/month.html +++ b/template/datepicker/month.html @@ -1,15 +1,15 @@ -
{{label}}{{label.abbr}}
{{ weekNumbers[$index] }} - + {{ weekNumbers[$index] }} +
+
- - - + + + - diff --git a/template/datepicker/popup.html b/template/datepicker/popup.html index f2ea1df7a6..fd48f60663 100644 --- a/template/datepicker/popup.html +++ b/template/datepicker/popup.html @@ -1,10 +1,10 @@ -
- + +
+
- - - + + + -
- + +