Skip to content

Commit

Permalink
feat(calendar): support specifying timezone in ng-model-options
Browse files Browse the repository at this point in the history
- remove calls to `focusAfterAppend.focus()` from both monthBody and yearBody
  - as these were breaking initial scrolling in day mode and selection in month mode
- fix cases where functions were being called with string or timestamp arguments
  - when they only accepted `Date` arguments
- add event types when calling `$setViewValue()`
- rename `CalendarCtrl.focus()` to `CalendarCtrl.focusDate()` to be more clear
  - and to avoid conflicts when analyzing code
- fix a number of JSDoc issues with types
- remove special initialization code from Calendar
  - it was working around angular#8585, which is now fixed

Fixes angular#10431
  • Loading branch information
Splaktar committed May 15, 2020
1 parent 5fbabe7 commit 2a01746
Show file tree
Hide file tree
Showing 10 changed files with 307 additions and 201 deletions.
305 changes: 184 additions & 121 deletions package-lock.json

Large diffs are not rendered by default.

130 changes: 82 additions & 48 deletions src/components/datepicker/js/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
* @module material.components.datepicker
*
* @param {Date} ng-model The component's model. Should be a Date object.
* @param {Object=} ng-model-options Allows tuning of the way in which `ng-model` is being
* updated. Also allows for a timezone to be specified.
* <a href="https://docs.angularjs.org/api/ng/directive/ngModelOptions#usage">Read more at the
* ngModelOptions docs.</a>
* @param {Date=} md-min-date Expression representing the minimum date.
* @param {Date=} md-max-date Expression representing the maximum date.
* @param {(function(Date): boolean)=} md-date-filter Function expecting a date and returning a
Expand Down Expand Up @@ -41,24 +45,16 @@
// TODO(jelbourn): Previous month opacity is lowered when partially scrolled out of view.
// TODO(jelbourn): Support md-calendar standalone on a page (as a tabstop w/ aria-live
// announcement and key handling).
// Read-only calendar (not just date-picker).
// TODO Read-only calendar (not just date-picker).

function calendarDirective() {
function calendarDirective(inputDirective) {
return {
template: function(tElement, tAttr) {
// TODO(crisbeto): This is a workaround that allows the calendar to work, without
// a datepicker, until issue #8585 gets resolved. It can safely be removed
// afterwards. This ensures that the virtual repeater scrolls to the proper place on load by
// deferring the execution until the next digest. It's necessary only if the calendar is used
// without a datepicker, otherwise it's already wrapped in an ngIf.
var extraAttrs = tAttr.hasOwnProperty('ngIf') ? '' : 'ng-if="calendarCtrl.isInitialized"';
var template = '' +
'<div ng-switch="calendarCtrl.currentView" ' + extraAttrs + '>' +
return '' +
'<div ng-switch="calendarCtrl.currentView">' +
'<md-calendar-year ng-switch-when="year"></md-calendar-year>' +
'<md-calendar-month ng-switch-default></md-calendar-month>' +
'</div>';

return template;
},
scope: {
minDate: '=mdMinDate',
Expand All @@ -77,7 +73,7 @@
link: function(scope, element, attrs, controllers) {
var ngModelCtrl = controllers[0];
var mdCalendarCtrl = controllers[1];
mdCalendarCtrl.configureNgModel(ngModelCtrl);
mdCalendarCtrl.configureNgModel(ngModelCtrl, inputDirective);
}
};
}
Expand Down Expand Up @@ -105,16 +101,28 @@
* @ngInject @constructor
*/
function CalendarCtrl($element, $scope, $$mdDateUtil, $mdUtil,
$mdConstant, $mdTheming, $$rAF, $attrs, $mdDateLocale) {
$mdConstant, $mdTheming, $$rAF, $attrs, $mdDateLocale, $filter) {

$mdTheming($element);

/** @final {!angular.JQLite} */
/**
* @final
* @type {!JQLite}
*/
this.$element = $element;

/** @final {!angular.Scope} */
/**
* @final
* @type {!angular.Scope}
*/
this.$scope = $scope;

/**
* @final
* @type {!angular.$attrs} Current attributes object for the element
*/
this.$attrs = $attrs;

/** @final */
this.dateUtil = $$mdDateUtil;

Expand All @@ -130,19 +138,25 @@
/** @final */
this.$mdDateLocale = $mdDateLocale;

/** @final {Date} */
/** @final The built-in Angular date filter. */
this.ngDateFilter = $filter('date');

/**
* @final
* @type {Date}
*/
this.today = this.dateUtil.createDateAtMidnight();

/** @type {!angular.NgModelController} */
/** @type {!ngModel.NgModelController} */
this.ngModelCtrl = null;

/** @type {String} Class applied to the selected date cell. */
/** @type {string} Class applied to the selected date cell. */
this.SELECTED_DATE_CLASS = 'md-calendar-selected-date';

/** @type {String} Class applied to the cell for today. */
/** @type {string} Class applied to the cell for today. */
this.TODAY_CLASS = 'md-calendar-date-today';

/** @type {String} Class applied to the focused cell. */
/** @type {string} Class applied to the focused cell. */
this.FOCUSED_DATE_CLASS = 'md-focus';

/** @final {number} Unique ID for this calendar instance. */
Expand All @@ -157,6 +171,12 @@
*/
this.displayDate = null;

/**
* Allows restricting the calendar to only allow selecting a month or a day.
* @type {'month'|'day'|null}
*/
this.mode = null;

/**
* The selected date. Keep track of this separately from the ng-model value so that we
* can know, when the ng-model value changes, what the previous value was before it's updated
Expand All @@ -180,12 +200,6 @@
*/
this.lastRenderableDate = null;

/**
* Used to toggle initialize the root element in the next digest.
* @type {Boolean}
*/
this.isInitialized = false;

/**
* Cache for the width of the element without a scrollbar. Used to hide the scrollbar later on
* and to avoid extra reflows when switching between views.
Expand Down Expand Up @@ -233,12 +247,12 @@
if (angular.version.major === 1 && angular.version.minor <= 4) {
this.$onInit();
}

}

/**
* AngularJS Lifecycle hook for newer AngularJS versions.
* Bindings are not guaranteed to have been assigned in the controller, but they are in the $onInit hook.
* Bindings are not guaranteed to have been assigned in the controller, but they are in the
* $onInit hook.
*/
CalendarCtrl.prototype.$onInit = function() {
/**
Expand All @@ -255,36 +269,55 @@
this.mode = null;
}

var dateLocale = this.$mdDateLocale;

if (this.minDate && this.minDate > dateLocale.firstRenderableDate) {
if (this.minDate && this.minDate > this.$mdDateLocale.firstRenderableDate) {
this.firstRenderableDate = this.minDate;
} else {
this.firstRenderableDate = dateLocale.firstRenderableDate;
this.firstRenderableDate = this.$mdDateLocale.firstRenderableDate;
}

if (this.maxDate && this.maxDate < dateLocale.lastRenderableDate) {
if (this.maxDate && this.maxDate < this.$mdDateLocale.lastRenderableDate) {
this.lastRenderableDate = this.maxDate;
} else {
this.lastRenderableDate = dateLocale.lastRenderableDate;
this.lastRenderableDate = this.$mdDateLocale.lastRenderableDate;
}
};

/**
* Sets up the controller's reference to ngModelController.
* @param {!angular.NgModelController} ngModelCtrl
* @param {!ngModel.NgModelController} ngModelCtrl Instance of the ngModel controller.
* @param {Object} inputDirective Config for Angular's `input` directive.
*/
CalendarCtrl.prototype.configureNgModel = function(ngModelCtrl) {
CalendarCtrl.prototype.configureNgModel = function(ngModelCtrl, inputDirective) {
var self = this;

self.ngModelCtrl = ngModelCtrl;

self.$mdUtil.nextTick(function() {
self.isInitialized = true;
});
// The component needs to be [type="date"] in order to be picked up by AngularJS.
this.$attrs.$set('type', 'date');

// Invoke the `input` directive link function, adding a stub for the element.
// This allows us to re-use AngularJS' logic for setting the timezone via ng-model-options.
// It works by calling the link function directly which then adds the proper `$parsers` and
// `$formatters` to the NgModelController.
inputDirective[0].link.pre(this.$scope, {
on: angular.noop,
val: angular.noop,
0: {}
}, this.$attrs, [ngModelCtrl]);

ngModelCtrl.$render = function() {
var value = this.$viewValue;
var parsedValue, convertedValue;

// In the case where a conversion is needed, the $viewValue here will be a string like
// "2020-05-10" instead of a Date object.
if (!self.dateUtil.isValidDate(value)) {
parsedValue = self.$mdDateLocale.parseDate(this.$viewValue);
convertedValue =
new Date(parsedValue.getTime() + 60000 * parsedValue.getTimezoneOffset());
if (self.dateUtil.isValidDate(convertedValue)) {
value = convertedValue;
}
}

// Notify the child scopes of any changes.
self.$scope.$broadcast('md-calendar-parent-changed', value);
Expand All @@ -303,13 +336,14 @@

/**
* Sets the ng-model value for the calendar and emits a change event.
* @param {Date} date
* @param {Date} date new value for the calendar
*/
CalendarCtrl.prototype.setNgModelValue = function(date) {
var timezone = this.$mdUtil.getModelOption(this.ngModelCtrl, 'timezone');
var value = this.dateUtil.createDateAtMidnight(date);
this.focus(value);
this.focusDate(value);
this.$scope.$emit('md-calendar-change', value);
this.ngModelCtrl.$setViewValue(value);
this.ngModelCtrl.$setViewValue(this.ngDateFilter(value, 'yyyy-MM-dd', timezone), 'default');
this.ngModelCtrl.$render();
return value;
};
Expand All @@ -333,9 +367,9 @@

/**
* Focus the cell corresponding to the given date.
* @param {Date} date The date to be focused.
* @param {Date=} date The date to be focused.
*/
CalendarCtrl.prototype.focus = function(date) {
CalendarCtrl.prototype.focusDate = function(date) {
if (this.dateUtil.isValidDate(date)) {
var previousFocus = this.$element[0].querySelector('.' + this.FOCUSED_DATE_CLASS);
if (previousFocus) {
Expand Down Expand Up @@ -424,10 +458,10 @@
this.$scope.$apply(function() {
// Capture escape and emit back up so that a wrapping component
// (such as a date-picker) can decide to close.
if (event.which == self.keyCode.ESCAPE || event.which == self.keyCode.TAB) {
if (event.which === self.keyCode.ESCAPE || event.which === self.keyCode.TAB) {
self.$scope.$emit('md-calendar-close');

if (event.which == self.keyCode.TAB) {
if (event.which === self.keyCode.TAB) {
event.preventDefault();
}

Expand Down
6 changes: 3 additions & 3 deletions src/components/datepicker/js/calendarMonth.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
this.cellClickHandler = function() {
var timestamp = $$mdDateUtil.getTimestampFromNode(this);
self.$scope.$apply(function() {
self.calendarCtrl.setNgModelValue(timestamp);
self.calendarCtrl.setNgModelValue(self.dateLocale.parseDate(timestamp));
});
};

Expand Down Expand Up @@ -145,7 +145,7 @@

/**
* Gets the "index" of the currently selected date as it would be in the virtual-repeat.
* @returns {number}
* @returns {number} the "index" of the currently selected date
*/
CalendarMonthCtrl.prototype.getSelectedMonthIndex = function() {
var calendarCtrl = this.calendarCtrl;
Expand Down Expand Up @@ -268,7 +268,7 @@
date = this.dateUtil.clampDate(date, calendarCtrl.minDate, calendarCtrl.maxDate);

this.changeDisplayDate(date).then(function() {
calendarCtrl.focus(date);
calendarCtrl.focusDate(date);
});
}
}
Expand Down
1 change: 0 additions & 1 deletion src/components/datepicker/js/calendarMonthBody.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@

if (this.focusAfterAppend) {
this.focusAfterAppend.classList.add(this.calendarCtrl.FOCUSED_DATE_CLASS);
this.focusAfterAppend.focus();
this.focusAfterAppend = null;
}
};
Expand Down
7 changes: 3 additions & 4 deletions src/components/datepicker/js/calendarYear.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@
* Controller for the mdCalendar component.
* @ngInject @constructor
*/
function CalendarYearCtrl($element, $scope, $animate, $q,
$$mdDateUtil, $mdUtil) {
function CalendarYearCtrl($element, $scope, $animate, $q, $$mdDateUtil, $mdUtil) {

/** @final {!angular.JQLite} */
this.$element = $element;
Expand Down Expand Up @@ -197,7 +196,7 @@
date = dateUtil.getFirstDateOfMonth(self.dateUtil.clampDate(date, min, max));

self.changeDate(date).then(function() {
calendarCtrl.focus(date);
calendarCtrl.focusDate(date);
});
}
}
Expand Down Expand Up @@ -228,7 +227,7 @@

if (calendarCtrl.mode) {
this.$mdUtil.nextTick(function() {
calendarCtrl.setNgModelValue(timestamp);
calendarCtrl.setNgModelValue(calendarCtrl.$mdDateLocale.parseDate(timestamp));
});
} else {
calendarCtrl.setCurrentView('month', timestamp);
Expand Down
3 changes: 1 addition & 2 deletions src/components/datepicker/js/calendarYearBody.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@

if (this.focusAfterAppend) {
this.focusAfterAppend.classList.add(this.calendarCtrl.FOCUSED_DATE_CLASS);
this.focusAfterAppend.focus();
this.focusAfterAppend = null;
}
};
Expand Down Expand Up @@ -163,7 +162,7 @@
var firstRow = document.createElement('tr');
var labelCell = document.createElement('td');
labelCell.className = 'md-calendar-month-label';
labelCell.textContent = year;
labelCell.textContent = String(year);
firstRow.appendChild(labelCell);

for (i = 0; i < 6; i++) {
Expand Down
3 changes: 2 additions & 1 deletion src/components/datepicker/js/dateLocaleProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@
* Factory function that returns an instance of the dateLocale service.
* @ngInject
* @param $locale
* @param $filter
* @returns {DateLocale}
*/
DateLocaleProvider.prototype.$get = function($locale, $filter) {
Expand Down Expand Up @@ -214,7 +215,7 @@

/**
* Default string-to-date parsing function.
* @param {string} dateString
* @param {string|number} dateString
* @returns {!Date}
*/
function defaultParseDate(dateString) {
Expand Down
18 changes: 9 additions & 9 deletions src/components/datepicker/js/dateUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
}

/**
* Gets whether two dates are the same day (not not necesarily the same time).
* Gets whether two dates are the same day (not not necessarily the same time).
* @param {Date} d1
* @param {Date} d2
* @returns {boolean}
Expand Down Expand Up @@ -207,19 +207,19 @@

/**
* Creates a date with the time set to midnight.
* Drop-in replacement for two forms of the Date constructor:
* 1. No argument for Date representing now.
* 2. Single-argument value representing number of seconds since Unix Epoch
* or a Date object.
* @param {number|Date=} opt_value
* Drop-in replacement for two forms of the Date constructor via opt_value.
* @param {number|Date=} opt_value Leave undefined for a Date representing now. Or use a
* single value representing the number of seconds since the Unix Epoch or a Date object.
* @return {Date} New date with time set to midnight.
*/
function createDateAtMidnight(opt_value) {
var date;
if (angular.isUndefined(opt_value)) {
date = new Date();
} else {
if (angular.isDate(opt_value)) {
date = opt_value;
} else if (angular.isNumber(opt_value)) {
date = new Date(opt_value);
} else {
date = new Date();
}
setDateTimeToMidnight(date);
return date;
Expand Down
Loading

0 comments on commit 2a01746

Please sign in to comment.