mcs-ng-material
Version:
MCS NG-Meterial is based on mcs-web.
1,440 lines (1,210 loc) • 114 kB
JavaScript
/*!
* AngularJS Material Design
* https://github.com/angular/material
* @license MIT
* v1.1.6
*/
goog.provide('ngmaterial.components.datepicker');
goog.require('ngmaterial.components.icon');
goog.require('ngmaterial.components.virtualRepeat');
goog.require('ngmaterial.core');
/**
* @ngdoc module
* @name material.components.datepicker
* @description Module for the datepicker component.
*/
angular.module('material.components.datepicker', [
'material.core',
'material.components.icon',
'material.components.virtualRepeat'
]);
(function() {
'use strict';
/**
* @ngdoc directive
* @name mdCalendar
* @module material.components.datepicker
*
* @param {Date} ng-model The component's model. Should be a Date object.
* @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 boolean whether it can be selected or not.
* @param {String=} md-current-view Current view of the calendar. Can be either "month" or "year".
* @param {String=} md-mode Restricts the user to only selecting a value from a particular view. This option can
* be used if the user is only supposed to choose from a certain date type (e.g. only selecting the month).
* Can be either "month" or "day". **Note** that this will ovewrite the `md-current-view` value.
*
* @description
* `<md-calendar>` is a component that renders a calendar that can be used to select a date.
* It is a part of the `<md-datepicker>` pane, however it can also be used on it's own.
*
* @usage
*
* <hljs lang="html">
* <md-calendar ng-model="birthday"></md-calendar>
* </hljs>
*/
CalendarCtrl['$inject'] = ["$element", "$scope", "$$mdDateUtil", "$mdUtil", "$mdConstant", "$mdTheming", "$$rAF", "$attrs", "$mdDateLocale"];
angular.module('material.components.datepicker')
.directive('mdCalendar', calendarDirective);
// POST RELEASE
// TODO(jelbourn): Mac Cmd + left / right == Home / End
// TODO(jelbourn): Refactor month element creation to use cloneNode (performance).
// TODO(jelbourn): Define virtual scrolling constants (compactness) users can override.
// TODO(jelbourn): Animated month transition on ng-model change (virtual-repeat)
// TODO(jelbourn): Scroll snapping (virtual repeat)
// TODO(jelbourn): Remove superfluous row from short months (virtual-repeat)
// TODO(jelbourn): Month headers stick to top when scrolling.
// 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).
function calendarDirective() {
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 + '>' +
'<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',
maxDate: '=mdMaxDate',
dateFilter: '=mdDateFilter',
// These need to be prefixed, because Angular resets
// any changes to the value due to bindToController.
_mode: '@mdMode',
_currentView: '@mdCurrentView'
},
require: ['ngModel', 'mdCalendar'],
controller: CalendarCtrl,
controllerAs: 'calendarCtrl',
bindToController: true,
link: function(scope, element, attrs, controllers) {
var ngModelCtrl = controllers[0];
var mdCalendarCtrl = controllers[1];
mdCalendarCtrl.configureNgModel(ngModelCtrl);
}
};
}
/**
* Occasionally the hideVerticalScrollbar method might read an element's
* width as 0, because it hasn't been laid out yet. This value will be used
* as a fallback, in order to prevent scenarios where the element's width
* would otherwise have been set to 0. This value is the "usual" width of a
* calendar within a floating calendar pane.
*/
var FALLBACK_WIDTH = 340;
/** Next identifier for calendar instance. */
var nextUniqueId = 0;
/** Maps the `md-mode` values to their corresponding calendar views. */
var MODE_MAP = {
day: 'month',
month: 'year'
};
/**
* Controller for the mdCalendar component.
* ngInject @constructor
*/
function CalendarCtrl($element, $scope, $$mdDateUtil, $mdUtil,
$mdConstant, $mdTheming, $$rAF, $attrs, $mdDateLocale) {
$mdTheming($element);
/** @final {!angular.JQLite} */
this.$element = $element;
/** @final {!angular.Scope} */
this.$scope = $scope;
/** @final */
this.dateUtil = $$mdDateUtil;
/** @final */
this.$mdUtil = $mdUtil;
/** @final */
this.keyCode = $mdConstant.KEY_CODE;
/** @final */
this.$$rAF = $$rAF;
/** @final */
this.$mdDateLocale = $mdDateLocale;
/** @final {Date} */
this.today = this.dateUtil.createDateAtMidnight();
/** @type {!angular.NgModelController} */
this.ngModelCtrl = null;
/** @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. */
this.TODAY_CLASS = 'md-calendar-date-today';
/** @type {String} Class applied to the focused cell. */
this.FOCUSED_DATE_CLASS = 'md-focus';
/** @final {number} Unique ID for this calendar instance. */
this.id = nextUniqueId++;
/**
* The date that is currently focused or showing in the calendar. This will initially be set
* to the ng-model value if set, otherwise to today. It will be updated as the user navigates
* to other months. The cell corresponding to the displayDate does not necesarily always have
* focus in the document (such as for cases when the user is scrolling the calendar).
* @type {Date}
*/
this.displayDate = 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
* in the component's UI.
*
* @type {Date}
*/
this.selectedDate = null;
/**
* The first date that can be rendered by the calendar. The default is taken
* from the mdDateLocale provider and is limited by the mdMinDate.
* @type {Date}
*/
this.firstRenderableDate = null;
/**
* The last date that can be rendered by the calendar. The default comes
* from the mdDateLocale provider and is limited by the maxDate.
* @type {Date}
*/
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.
* @type {Number}
*/
this.width = 0;
/**
* Caches the width of the scrollbar in order to be used when hiding it and to avoid extra reflows.
* @type {Number}
*/
this.scrollbarWidth = 0;
// Unless the user specifies so, the calendar should not be a tab stop.
// This is necessary because ngAria might add a tabindex to anything with an ng-model
// (based on whether or not the user has turned that particular feature on/off).
if (!$attrs.tabindex) {
$element.attr('tabindex', '-1');
}
var boundKeyHandler = angular.bind(this, this.handleKeyEvent);
// If use the md-calendar directly in the body without datepicker,
// handleKeyEvent will disable other inputs on the page.
// So only apply the handleKeyEvent on the body when the md-calendar inside datepicker,
// otherwise apply on the calendar element only.
var handleKeyElement;
if ($element.parent().hasClass('md-datepicker-calendar')) {
handleKeyElement = angular.element(document.body);
} else {
handleKeyElement = $element;
}
// Bind the keydown handler to the body, in order to handle cases where the focused
// element gets removed from the DOM and stops propagating click events.
handleKeyElement.on('keydown', boundKeyHandler);
$scope.$on('$destroy', function() {
handleKeyElement.off('keydown', boundKeyHandler);
});
// For AngularJS 1.4 and older, where there are no lifecycle hooks but bindings are pre-assigned,
// manually call the $onInit hook.
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.
*/
CalendarCtrl.prototype.$onInit = function() {
/**
* The currently visible calendar view. Note the prefix on the scope value,
* which is necessary, because the datepicker seems to reset the real one value if the
* calendar is open, but the `currentView` on the datepicker's scope is empty.
* @type {String}
*/
if (this._mode && MODE_MAP.hasOwnProperty(this._mode)) {
this.currentView = MODE_MAP[this._mode];
this.mode = this._mode;
} else {
this.currentView = this._currentView || 'month';
this.mode = null;
}
var dateLocale = this.$mdDateLocale;
if (this.minDate && this.minDate > dateLocale.firstRenderableDate) {
this.firstRenderableDate = this.minDate;
} else {
this.firstRenderableDate = dateLocale.firstRenderableDate;
}
if (this.maxDate && this.maxDate < dateLocale.lastRenderableDate) {
this.lastRenderableDate = this.maxDate;
} else {
this.lastRenderableDate = dateLocale.lastRenderableDate;
}
};
/**
* Sets up the controller's reference to ngModelController.
* @param {!angular.NgModelController} ngModelCtrl
*/
CalendarCtrl.prototype.configureNgModel = function(ngModelCtrl) {
var self = this;
self.ngModelCtrl = ngModelCtrl;
self.$mdUtil.nextTick(function() {
self.isInitialized = true;
});
ngModelCtrl.$render = function() {
var value = this.$viewValue;
// Notify the child scopes of any changes.
self.$scope.$broadcast('md-calendar-parent-changed', value);
// Set up the selectedDate if it hasn't been already.
if (!self.selectedDate) {
self.selectedDate = value;
}
// Also set up the displayDate.
if (!self.displayDate) {
self.displayDate = self.selectedDate || self.today;
}
};
};
/**
* Sets the ng-model value for the calendar and emits a change event.
* @param {Date} date
*/
CalendarCtrl.prototype.setNgModelValue = function(date) {
var value = this.dateUtil.createDateAtMidnight(date);
this.focus(value);
this.$scope.$emit('md-calendar-change', value);
this.ngModelCtrl.$setViewValue(value);
this.ngModelCtrl.$render();
return value;
};
/**
* Sets the current view that should be visible in the calendar
* @param {string} newView View name to be set.
* @param {number|Date} time Date object or a timestamp for the new display date.
*/
CalendarCtrl.prototype.setCurrentView = function(newView, time) {
var self = this;
self.$mdUtil.nextTick(function() {
self.currentView = newView;
if (time) {
self.displayDate = angular.isDate(time) ? time : new Date(time);
}
});
};
/**
* Focus the cell corresponding to the given date.
* @param {Date} date The date to be focused.
*/
CalendarCtrl.prototype.focus = function(date) {
if (this.dateUtil.isValidDate(date)) {
var previousFocus = this.$element[0].querySelector('.' + this.FOCUSED_DATE_CLASS);
if (previousFocus) {
previousFocus.classList.remove(this.FOCUSED_DATE_CLASS);
}
var cellId = this.getDateId(date, this.currentView);
var cell = document.getElementById(cellId);
if (cell) {
cell.classList.add(this.FOCUSED_DATE_CLASS);
cell.focus();
this.displayDate = date;
}
} else {
var rootElement = this.$element[0].querySelector('[ng-switch]');
if (rootElement) {
rootElement.focus();
}
}
};
/**
* Highlights a date cell on the calendar and changes the selected date.
* @param {Date=} date Date to be marked as selected.
*/
CalendarCtrl.prototype.changeSelectedDate = function(date) {
var selectedDateClass = this.SELECTED_DATE_CLASS;
var prevDateCell = this.$element[0].querySelector('.' + selectedDateClass);
// Remove the selected class from the previously selected date, if any.
if (prevDateCell) {
prevDateCell.classList.remove(selectedDateClass);
prevDateCell.setAttribute('aria-selected', 'false');
}
// Apply the select class to the new selected date if it is set.
if (date) {
var dateCell = document.getElementById(this.getDateId(date, this.currentView));
if (dateCell) {
dateCell.classList.add(selectedDateClass);
dateCell.setAttribute('aria-selected', 'true');
}
}
this.selectedDate = date;
};
/**
* Normalizes the key event into an action name. The action will be broadcast
* to the child controllers.
* @param {KeyboardEvent} event
* @returns {String} The action that should be taken, or null if the key
* does not match a calendar shortcut.
*/
CalendarCtrl.prototype.getActionFromKeyEvent = function(event) {
var keyCode = this.keyCode;
switch (event.which) {
case keyCode.ENTER: return 'select';
case keyCode.RIGHT_ARROW: return 'move-right';
case keyCode.LEFT_ARROW: return 'move-left';
case keyCode.DOWN_ARROW: return event.metaKey ? 'move-page-down' : 'move-row-down';
case keyCode.UP_ARROW: return event.metaKey ? 'move-page-up' : 'move-row-up';
case keyCode.PAGE_DOWN: return 'move-page-down';
case keyCode.PAGE_UP: return 'move-page-up';
case keyCode.HOME: return 'start';
case keyCode.END: return 'end';
default: return null;
}
};
/**
* Handles a key event in the calendar with the appropriate action. The action will either
* be to select the focused date or to navigate to focus a new date.
* @param {KeyboardEvent} event
*/
CalendarCtrl.prototype.handleKeyEvent = function(event) {
var self = this;
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) {
self.$scope.$emit('md-calendar-close');
if (event.which == self.keyCode.TAB) {
event.preventDefault();
}
return;
}
// Broadcast the action that any child controllers should take.
var action = self.getActionFromKeyEvent(event);
if (action) {
event.preventDefault();
event.stopPropagation();
self.$scope.$broadcast('md-calendar-parent-action', action);
}
});
};
/**
* Hides the vertical scrollbar on the calendar scroller of a child controller by
* setting the width on the calendar scroller and the `overflow: hidden` wrapper
* around the scroller, and then setting a padding-right on the scroller equal
* to the width of the browser's scrollbar.
*
* This will cause a reflow.
*
* @param {object} childCtrl The child controller whose scrollbar should be hidden.
*/
CalendarCtrl.prototype.hideVerticalScrollbar = function(childCtrl) {
var self = this;
var element = childCtrl.$element[0];
var scrollMask = element.querySelector('.md-calendar-scroll-mask');
if (self.width > 0) {
setWidth();
} else {
self.$$rAF(function() {
var scroller = childCtrl.calendarScroller;
self.scrollbarWidth = scroller.offsetWidth - scroller.clientWidth;
self.width = element.querySelector('table').offsetWidth;
setWidth();
});
}
function setWidth() {
var width = self.width || FALLBACK_WIDTH;
var scrollbarWidth = self.scrollbarWidth;
var scroller = childCtrl.calendarScroller;
scrollMask.style.width = width + 'px';
scroller.style.width = (width + scrollbarWidth) + 'px';
scroller.style.paddingRight = scrollbarWidth + 'px';
}
};
/**
* Gets an identifier for a date unique to the calendar instance for internal
* purposes. Not to be displayed.
* @param {Date} date The date for which the id is being generated
* @param {string} namespace Namespace for the id. (month, year etc.)
* @returns {string}
*/
CalendarCtrl.prototype.getDateId = function(date, namespace) {
if (!namespace) {
throw new Error('A namespace for the date id has to be specified.');
}
return [
'md',
this.id,
namespace,
date.getFullYear(),
date.getMonth(),
date.getDate()
].join('-');
};
/**
* Util to trigger an extra digest on a parent scope, in order to to ensure that
* any child virtual repeaters have updated. This is necessary, because the virtual
* repeater doesn't update the $index the first time around since the content isn't
* in place yet. The case, in which this is an issue, is when the repeater has less
* than a page of content (e.g. a month or year view has a min or max date).
*/
CalendarCtrl.prototype.updateVirtualRepeat = function() {
var scope = this.$scope;
var virtualRepeatResizeListener = scope.$on('$md-resize-enable', function() {
if (!scope.$$phase) {
scope.$apply();
}
virtualRepeatResizeListener();
});
};
})();
(function() {
'use strict';
CalendarMonthCtrl['$inject'] = ["$element", "$scope", "$animate", "$q", "$$mdDateUtil", "$mdDateLocale"];
angular.module('material.components.datepicker')
.directive('mdCalendarMonth', calendarDirective);
/**
* Height of one calendar month tbody. This must be made known to the virtual-repeat and is
* subsequently used for scrolling to specific months.
*/
var TBODY_HEIGHT = 265;
/**
* Height of a calendar month with a single row. This is needed to calculate the offset for
* rendering an extra month in virtual-repeat that only contains one row.
*/
var TBODY_SINGLE_ROW_HEIGHT = 45;
/** Private directive that represents a list of months inside the calendar. */
function calendarDirective() {
return {
template:
'<table aria-hidden="true" class="md-calendar-day-header"><thead></thead></table>' +
'<div class="md-calendar-scroll-mask">' +
'<md-virtual-repeat-container class="md-calendar-scroll-container" ' +
'md-offset-size="' + (TBODY_SINGLE_ROW_HEIGHT - TBODY_HEIGHT) + '">' +
'<table role="grid" tabindex="0" class="md-calendar" aria-readonly="true">' +
'<tbody ' +
'md-calendar-month-body ' +
'role="rowgroup" ' +
'md-virtual-repeat="i in monthCtrl.items" ' +
'md-month-offset="$index" ' +
'class="md-calendar-month" ' +
'md-start-index="monthCtrl.getSelectedMonthIndex()" ' +
'md-item-size="' + TBODY_HEIGHT + '">' +
// The <tr> ensures that the <tbody> will always have the
// proper height, even if it's empty. If it's content is
// compiled, the <tr> will be overwritten.
'<tr aria-hidden="true" md-force-height="\'' + TBODY_HEIGHT + 'px\'"></tr>' +
'</tbody>' +
'</table>' +
'</md-virtual-repeat-container>' +
'</div>',
require: ['^^mdCalendar', 'mdCalendarMonth'],
controller: CalendarMonthCtrl,
controllerAs: 'monthCtrl',
bindToController: true,
link: function(scope, element, attrs, controllers) {
var calendarCtrl = controllers[0];
var monthCtrl = controllers[1];
monthCtrl.initialize(calendarCtrl);
}
};
}
/**
* Controller for the calendar month component.
* ngInject @constructor
*/
function CalendarMonthCtrl($element, $scope, $animate, $q,
$$mdDateUtil, $mdDateLocale) {
/** @final {!angular.JQLite} */
this.$element = $element;
/** @final {!angular.Scope} */
this.$scope = $scope;
/** @final {!angular.$animate} */
this.$animate = $animate;
/** @final {!angular.$q} */
this.$q = $q;
/** @final */
this.dateUtil = $$mdDateUtil;
/** @final */
this.dateLocale = $mdDateLocale;
/** @final {HTMLElement} */
this.calendarScroller = $element[0].querySelector('.md-virtual-repeat-scroller');
/** @type {boolean} */
this.isInitialized = false;
/** @type {boolean} */
this.isMonthTransitionInProgress = false;
var self = this;
/**
* Handles a click event on a date cell.
* Created here so that every cell can use the same function instance.
* @this {HTMLTableCellElement} The cell that was clicked.
*/
this.cellClickHandler = function() {
var timestamp = $$mdDateUtil.getTimestampFromNode(this);
self.$scope.$apply(function() {
self.calendarCtrl.setNgModelValue(timestamp);
});
};
/**
* Handles click events on the month headers. Switches
* the calendar to the year view.
* @this {HTMLTableCellElement} The cell that was clicked.
*/
this.headerClickHandler = function() {
self.calendarCtrl.setCurrentView('year', $$mdDateUtil.getTimestampFromNode(this));
};
}
/*** Initialization ***/
/**
* Initialize the controller by saving a reference to the calendar and
* setting up the object that will be iterated by the virtual repeater.
*/
CalendarMonthCtrl.prototype.initialize = function(calendarCtrl) {
/**
* Dummy array-like object for virtual-repeat to iterate over. The length is the total
* number of months that can be viewed. We add 2 months: one to include the current month
* and one for the last dummy month.
*
* This is shorter than ideal because of a (potential) Firefox bug
* https://bugzilla.mozilla.org/show_bug.cgi?id=1181658.
*/
this.items = {
length: this.dateUtil.getMonthDistance(
calendarCtrl.firstRenderableDate,
calendarCtrl.lastRenderableDate
) + 2
};
this.calendarCtrl = calendarCtrl;
this.attachScopeListeners();
calendarCtrl.updateVirtualRepeat();
// Fire the initial render, since we might have missed it the first time it fired.
calendarCtrl.ngModelCtrl && calendarCtrl.ngModelCtrl.$render();
};
/**
* Gets the "index" of the currently selected date as it would be in the virtual-repeat.
* @returns {number}
*/
CalendarMonthCtrl.prototype.getSelectedMonthIndex = function() {
var calendarCtrl = this.calendarCtrl;
return this.dateUtil.getMonthDistance(
calendarCtrl.firstRenderableDate,
calendarCtrl.displayDate || calendarCtrl.selectedDate || calendarCtrl.today
);
};
/**
* Change the date that is being shown in the calendar. If the given date is in a different
* month, the displayed month will be transitioned.
* @param {Date} date
*/
CalendarMonthCtrl.prototype.changeDisplayDate = function(date) {
// Initialization is deferred until this function is called because we want to reflect
// the starting value of ngModel.
if (!this.isInitialized) {
this.buildWeekHeader();
this.calendarCtrl.hideVerticalScrollbar(this);
this.isInitialized = true;
return this.$q.when();
}
// If trying to show an invalid date or a transition is in progress, do nothing.
if (!this.dateUtil.isValidDate(date) || this.isMonthTransitionInProgress) {
return this.$q.when();
}
this.isMonthTransitionInProgress = true;
var animationPromise = this.animateDateChange(date);
this.calendarCtrl.displayDate = date;
var self = this;
animationPromise.then(function() {
self.isMonthTransitionInProgress = false;
});
return animationPromise;
};
/**
* Animates the transition from the calendar's current month to the given month.
* @param {Date} date
* @returns {angular.$q.Promise} The animation promise.
*/
CalendarMonthCtrl.prototype.animateDateChange = function(date) {
if (this.dateUtil.isValidDate(date)) {
var monthDistance = this.dateUtil.getMonthDistance(this.calendarCtrl.firstRenderableDate, date);
this.calendarScroller.scrollTop = monthDistance * TBODY_HEIGHT;
}
return this.$q.when();
};
/**
* Builds and appends a day-of-the-week header to the calendar.
* This should only need to be called once during initialization.
*/
CalendarMonthCtrl.prototype.buildWeekHeader = function() {
var firstDayOfWeek = this.dateLocale.firstDayOfWeek;
var shortDays = this.dateLocale.shortDays;
var row = document.createElement('tr');
for (var i = 0; i < 7; i++) {
var th = document.createElement('th');
th.textContent = shortDays[(i + firstDayOfWeek) % 7];
row.appendChild(th);
}
this.$element.find('thead').append(row);
};
/**
* Attaches listeners for the scope events that are broadcast by the calendar.
*/
CalendarMonthCtrl.prototype.attachScopeListeners = function() {
var self = this;
self.$scope.$on('md-calendar-parent-changed', function(event, value) {
self.calendarCtrl.changeSelectedDate(value);
self.changeDisplayDate(value);
});
self.$scope.$on('md-calendar-parent-action', angular.bind(this, this.handleKeyEvent));
};
/**
* Handles the month-specific keyboard interactions.
* @param {Object} event Scope event object passed by the calendar.
* @param {String} action Action, corresponding to the key that was pressed.
*/
CalendarMonthCtrl.prototype.handleKeyEvent = function(event, action) {
var calendarCtrl = this.calendarCtrl;
var displayDate = calendarCtrl.displayDate;
if (action === 'select') {
calendarCtrl.setNgModelValue(displayDate);
} else {
var date = null;
var dateUtil = this.dateUtil;
switch (action) {
case 'move-right': date = dateUtil.incrementDays(displayDate, 1); break;
case 'move-left': date = dateUtil.incrementDays(displayDate, -1); break;
case 'move-page-down': date = dateUtil.incrementMonths(displayDate, 1); break;
case 'move-page-up': date = dateUtil.incrementMonths(displayDate, -1); break;
case 'move-row-down': date = dateUtil.incrementDays(displayDate, 7); break;
case 'move-row-up': date = dateUtil.incrementDays(displayDate, -7); break;
case 'start': date = dateUtil.getFirstDateOfMonth(displayDate); break;
case 'end': date = dateUtil.getLastDateOfMonth(displayDate); break;
}
if (date) {
date = this.dateUtil.clampDate(date, calendarCtrl.minDate, calendarCtrl.maxDate);
this.changeDisplayDate(date).then(function() {
calendarCtrl.focus(date);
});
}
}
};
})();
(function() {
'use strict';
mdCalendarMonthBodyDirective['$inject'] = ["$compile", "$$mdSvgRegistry"];
CalendarMonthBodyCtrl['$inject'] = ["$element", "$$mdDateUtil", "$mdDateLocale"];
angular.module('material.components.datepicker')
.directive('mdCalendarMonthBody', mdCalendarMonthBodyDirective);
/**
* Private directive consumed by md-calendar-month. Having this directive lets the calender use
* md-virtual-repeat and also cleanly separates the month DOM construction functions from
* the rest of the calendar controller logic.
* ngInject
*/
function mdCalendarMonthBodyDirective($compile, $$mdSvgRegistry) {
var ARROW_ICON = $compile('<md-icon md-svg-src="' +
$$mdSvgRegistry.mdTabsArrow + '"></md-icon>')({})[0];
return {
require: ['^^mdCalendar', '^^mdCalendarMonth', 'mdCalendarMonthBody'],
scope: { offset: '=mdMonthOffset' },
controller: CalendarMonthBodyCtrl,
controllerAs: 'mdMonthBodyCtrl',
bindToController: true,
link: function(scope, element, attrs, controllers) {
var calendarCtrl = controllers[0];
var monthCtrl = controllers[1];
var monthBodyCtrl = controllers[2];
monthBodyCtrl.calendarCtrl = calendarCtrl;
monthBodyCtrl.monthCtrl = monthCtrl;
monthBodyCtrl.arrowIcon = ARROW_ICON.cloneNode(true);
// The virtual-repeat re-uses the same DOM elements, so there are only a limited number
// of repeated items that are linked, and then those elements have their bindings updated.
// Since the months are not generated by bindings, we simply regenerate the entire thing
// when the binding (offset) changes.
scope.$watch(function() { return monthBodyCtrl.offset; }, function(offset) {
if (angular.isNumber(offset)) {
monthBodyCtrl.generateContent();
}
});
}
};
}
/**
* Controller for a single calendar month.
* ngInject @constructor
*/
function CalendarMonthBodyCtrl($element, $$mdDateUtil, $mdDateLocale) {
/** @final {!angular.JQLite} */
this.$element = $element;
/** @final */
this.dateUtil = $$mdDateUtil;
/** @final */
this.dateLocale = $mdDateLocale;
/** @type {Object} Reference to the month view. */
this.monthCtrl = null;
/** @type {Object} Reference to the calendar. */
this.calendarCtrl = null;
/**
* Number of months from the start of the month "items" that the currently rendered month
* occurs. Set via angular data binding.
* @type {number}
*/
this.offset = null;
/**
* Date cell to focus after appending the month to the document.
* @type {HTMLElement}
*/
this.focusAfterAppend = null;
}
/** Generate and append the content for this month to the directive element. */
CalendarMonthBodyCtrl.prototype.generateContent = function() {
var date = this.dateUtil.incrementMonths(this.calendarCtrl.firstRenderableDate, this.offset);
this.$element
.empty()
.append(this.buildCalendarForMonth(date));
if (this.focusAfterAppend) {
this.focusAfterAppend.classList.add(this.calendarCtrl.FOCUSED_DATE_CLASS);
this.focusAfterAppend.focus();
this.focusAfterAppend = null;
}
};
/**
* Creates a single cell to contain a date in the calendar with all appropriate
* attributes and classes added. If a date is given, the cell content will be set
* based on the date.
* @param {Date=} opt_date
* @returns {HTMLElement}
*/
CalendarMonthBodyCtrl.prototype.buildDateCell = function(opt_date) {
var monthCtrl = this.monthCtrl;
var calendarCtrl = this.calendarCtrl;
// TODO(jelbourn): cloneNode is likely a faster way of doing this.
var cell = document.createElement('td');
cell.tabIndex = -1;
cell.classList.add('md-calendar-date');
cell.setAttribute('role', 'gridcell');
if (opt_date) {
cell.setAttribute('tabindex', '-1');
cell.setAttribute('aria-label', this.dateLocale.longDateFormatter(opt_date));
cell.id = calendarCtrl.getDateId(opt_date, 'month');
// Use `data-timestamp` attribute because IE10 does not support the `dataset` property.
cell.setAttribute('data-timestamp', opt_date.getTime());
// TODO(jelourn): Doing these comparisons for class addition during generation might be slow.
// It may be better to finish the construction and then query the node and add the class.
if (this.dateUtil.isSameDay(opt_date, calendarCtrl.today)) {
cell.classList.add(calendarCtrl.TODAY_CLASS);
}
if (this.dateUtil.isValidDate(calendarCtrl.selectedDate) &&
this.dateUtil.isSameDay(opt_date, calendarCtrl.selectedDate)) {
cell.classList.add(calendarCtrl.SELECTED_DATE_CLASS);
cell.setAttribute('aria-selected', 'true');
}
var cellText = this.dateLocale.dates[opt_date.getDate()];
if (this.isDateEnabled(opt_date)) {
// Add a indicator for select, hover, and focus states.
var selectionIndicator = document.createElement('span');
selectionIndicator.classList.add('md-calendar-date-selection-indicator');
selectionIndicator.textContent = cellText;
cell.appendChild(selectionIndicator);
cell.addEventListener('click', monthCtrl.cellClickHandler);
if (calendarCtrl.displayDate && this.dateUtil.isSameDay(opt_date, calendarCtrl.displayDate)) {
this.focusAfterAppend = cell;
}
} else {
cell.classList.add('md-calendar-date-disabled');
cell.textContent = cellText;
}
}
return cell;
};
/**
* Check whether date is in range and enabled
* @param {Date=} opt_date
* @return {boolean} Whether the date is enabled.
*/
CalendarMonthBodyCtrl.prototype.isDateEnabled = function(opt_date) {
return this.dateUtil.isDateWithinRange(opt_date,
this.calendarCtrl.minDate, this.calendarCtrl.maxDate) &&
(!angular.isFunction(this.calendarCtrl.dateFilter)
|| this.calendarCtrl.dateFilter(opt_date));
};
/**
* Builds a `tr` element for the calendar grid.
* @param rowNumber The week number within the month.
* @returns {HTMLElement}
*/
CalendarMonthBodyCtrl.prototype.buildDateRow = function(rowNumber) {
var row = document.createElement('tr');
row.setAttribute('role', 'row');
// Because of an NVDA bug (with Firefox), the row needs an aria-label in order
// to prevent the entire row being read aloud when the user moves between rows.
// See http://community.nvda-project.org/ticket/4643.
row.setAttribute('aria-label', this.dateLocale.weekNumberFormatter(rowNumber));
return row;
};
/**
* Builds the <tbody> content for the given date's month.
* @param {Date=} opt_dateInMonth
* @returns {DocumentFragment} A document fragment containing the <tr> elements.
*/
CalendarMonthBodyCtrl.prototype.buildCalendarForMonth = function(opt_dateInMonth) {
var date = this.dateUtil.isValidDate(opt_dateInMonth) ? opt_dateInMonth : new Date();
var firstDayOfMonth = this.dateUtil.getFirstDateOfMonth(date);
var firstDayOfTheWeek = this.getLocaleDay_(firstDayOfMonth);
var numberOfDaysInMonth = this.dateUtil.getNumberOfDaysInMonth(date);
// Store rows for the month in a document fragment so that we can append them all at once.
var monthBody = document.createDocumentFragment();
var rowNumber = 1;
var row = this.buildDateRow(rowNumber);
monthBody.appendChild(row);
// If this is the final month in the list of items, only the first week should render,
// so we should return immediately after the first row is complete and has been
// attached to the body.
var isFinalMonth = this.offset === this.monthCtrl.items.length - 1;
// Add a label for the month. If the month starts on a Sun/Mon/Tues, the month label
// goes on a row above the first of the month. Otherwise, the month label takes up the first
// two cells of the first row.
var blankCellOffset = 0;
var monthLabelCell = document.createElement('td');
var monthLabelCellContent = document.createElement('span');
var calendarCtrl = this.calendarCtrl;
monthLabelCellContent.textContent = this.dateLocale.monthHeaderFormatter(date);
monthLabelCell.appendChild(monthLabelCellContent);
monthLabelCell.classList.add('md-calendar-month-label');
// If the entire month is after the max date, render the label as a disabled state.
if (calendarCtrl.maxDate && firstDayOfMonth > calendarCtrl.maxDate) {
monthLabelCell.classList.add('md-calendar-month-label-disabled');
// If the user isn't supposed to be able to change views, render the
// label as usual, but disable the clicking functionality.
} else if (!calendarCtrl.mode) {
monthLabelCell.addEventListener('click', this.monthCtrl.headerClickHandler);
monthLabelCell.setAttribute('data-timestamp', firstDayOfMonth.getTime());
monthLabelCell.setAttribute('aria-label', this.dateLocale.monthFormatter(date));
monthLabelCell.classList.add('md-calendar-label-clickable');
monthLabelCell.appendChild(this.arrowIcon.cloneNode(true));
}
if (firstDayOfTheWeek <= 2) {
monthLabelCell.setAttribute('colspan', '7');
var monthLabelRow = this.buildDateRow();
monthLabelRow.appendChild(monthLabelCell);
monthBody.insertBefore(monthLabelRow, row);
if (isFinalMonth) {
return monthBody;
}
} else {
blankCellOffset = 3;
monthLabelCell.setAttribute('colspan', '3');
row.appendChild(monthLabelCell);
}
// Add a blank cell for each day of the week that occurs before the first of the month.
// For example, if the first day of the month is a Tuesday, add blank cells for Sun and Mon.
// The blankCellOffset is needed in cases where the first N cells are used by the month label.
for (var i = blankCellOffset; i < firstDayOfTheWeek; i++) {
row.appendChild(this.buildDateCell());
}
// Add a cell for each day of the month, keeping track of the day of the week so that
// we know when to start a new row.
var dayOfWeek = firstDayOfTheWeek;
var iterationDate = firstDayOfMonth;
for (var d = 1; d <= numberOfDaysInMonth; d++) {
// If we've reached the end of the week, start a new row.
if (dayOfWeek === 7) {
// We've finished the first row, so we're done if this is the final month.
if (isFinalMonth) {
return monthBody;
}
dayOfWeek = 0;
rowNumber++;
row = this.buildDateRow(rowNumber);
monthBody.appendChild(row);
}
iterationDate.setDate(d);
var cell = this.buildDateCell(iterationDate);
row.appendChild(cell);
dayOfWeek++;
}
// Ensure that the last row of the month has 7 cells.
while (row.childNodes.length < 7) {
row.appendChild(this.buildDateCell());
}
// Ensure that all months have 6 rows. This is necessary for now because the virtual-repeat
// requires that all items have exactly the same height.
while (monthBody.childNodes.length < 6) {
var whitespaceRow = this.buildDateRow();
for (var j = 0; j < 7; j++) {
whitespaceRow.appendChild(this.buildDateCell());
}
monthBody.appendChild(whitespaceRow);
}
return monthBody;
};
/**
* Gets the day-of-the-week index for a date for the current locale.
* @private
* @param {Date} date
* @returns {number} The column index of the date in the calendar.
*/
CalendarMonthBodyCtrl.prototype.getLocaleDay_ = function(date) {
return (date.getDay() + (7 - this.dateLocale.firstDayOfWeek)) % 7;
};
})();
(function() {
'use strict';
CalendarYearCtrl['$inject'] = ["$element", "$scope", "$animate", "$q", "$$mdDateUtil", "$mdUtil"];
angular.module('material.components.datepicker')
.directive('mdCalendarYear', calendarDirective);
/**
* Height of one calendar year tbody. This must be made known to the virtual-repeat and is
* subsequently used for scrolling to specific years.
*/
var TBODY_HEIGHT = 88;
/** Private component, representing a list of years in the calendar. */
function calendarDirective() {
return {
template:
'<div class="md-calendar-scroll-mask">' +
'<md-virtual-repeat-container class="md-calendar-scroll-container">' +
'<table role="grid" tabindex="0" class="md-calendar" aria-readonly="true">' +
'<tbody ' +
'md-calendar-year-body ' +
'role="rowgroup" ' +
'md-virtual-repeat="i in yearCtrl.items" ' +
'md-year-offset="$index" class="md-calendar-year" ' +
'md-start-index="yearCtrl.getFocusedYearIndex()" ' +
'md-item-size="' + TBODY_HEIGHT + '">' +
// The <tr> ensures that the <tbody> will have the proper
// height, even though it may be empty.
'<tr aria-hidden="true" md-force-height="\'' + TBODY_HEIGHT + 'px\'"></tr>' +
'</tbody>' +
'</table>' +
'</md-virtual-repeat-container>' +
'</div>',
require: ['^^mdCalendar', 'mdCalendarYear'],
controller: CalendarYearCtrl,
controllerAs: 'yearCtrl',
bindToController: true,
link: function(scope, element, attrs, controllers) {
var calendarCtrl = controllers[0];
var yearCtrl = controllers[1];
yearCtrl.initialize(calendarCtrl);
}
};
}
/**
* Controller for the mdCalendar component.
* ngInject @constructor
*/
function CalendarYearCtrl($element, $scope, $animate, $q,
$$mdDateUtil, $mdUtil) {
/** @final {!angular.JQLite} */
this.$element = $element;
/** @final {!angular.Scope} */
this.$scope = $scope;
/** @final {!angular.$animate} */
this.$animate = $animate;
/** @final {!angular.$q} */
this.$q = $q;
/** @final */
this.dateUtil = $$mdDateUtil;
/** @final {HTMLElement} */
this.calendarScroller = $element[0].querySelector('.md-virtual-repeat-scroller');
/** @type {boolean} */
this.isInitialized = false;
/** @type {boolean} */
this.isMonthTransitionInProgress = false;
/** @final */
this.$mdUtil = $mdUtil;
var self = this;
/**
* Handles a click event on a date cell.
* Created here so that every cell can use the same function instance.
* @this {HTMLTableCellElement} The cell that was clicked.
*/
this.cellClickHandler = function() {
self.onTimestampSelected($$mdDateUtil.getTimestampFromNode(this));
};
}
/**
* Initialize the controller by saving a reference to the calendar and
* setting up the object that will be iterated by the virtual repeater.
*/
CalendarYearCtrl.prototype.initialize = function(calendarCtrl) {
/**
* Dummy array-like object for virtual-repeat to iterate over. The length is the total
* number of years that can be viewed. We add 1 extra in order to include the current year.
*/
this.items = {
length: this.dateUtil.getYearDistance(
calendarCtrl.firstRenderableDate,
calendarCtrl.lastRenderableDate
) + 1
};
this.calendarCtrl = calendarCtrl;
this.attachScopeListeners();
calendarCtrl.updateVirtualRepeat();
// Fire the initial render, since we might have missed it the first time it fired.
calendarCtrl.ngModelCtrl && calendarCtrl.ngModelCtrl.$render();
};
/**
* Gets the "index" of the currently selected date as it would be in the virtual-repeat.
* @returns {number}
*/
CalendarYearCtrl.prototype.getFocusedYearIndex = function() {
var calendarCtrl = this.calendarCtrl;
return this.dateUtil.getYearDistance(
calendarCtrl.firstRenderableDate,
calendarCtrl.displayDate || calendarCtrl.selectedDate || calendarCtrl.today
);
};
/**
* Change the date that is highlighted in the calendar.
* @param {Date} date
*/
CalendarYearCtrl.prototype.changeDate = function(date) {
// Initialization is deferred until this function is called because we want to reflect
// the starting value of ngModel.
if (!this.isInitialized) {
this.calendarCtrl.hideVerticalScrollbar(this);
this.isInitialized = true;
return this.$q.when();
} else if (this.dateUtil.isValidDate(date) && !this.isMonthTransitionInProgress) {
var self = this;
var animationPromise = this.animateDateChange(date);
self.isMonthTransitionInProgress = true;
self.calendarCtrl.displayDate = date;
return animationPromise.then(function() {
self.isMonthTransitionInProgress = false;
});
}
};
/**
* Animates the transition from the calendar's current month to the given month.
* @param {Date} date
* @returns {angular.$q.Promise} The animation promise.
*/
CalendarYearCtrl.prototype.animateDateChange = function(date) {
if (this.dateUtil.isValidDate(date)) {
var monthDistance = this.dateUtil.getYearDistance(this.calendarCtrl.firstRenderableDate, date);
this.calendarScroller.scrollTop = monthDistance * TBODY_HEIGHT;
}
return this.$q.when();
};
/**
* Handles the year-view-specific keyboard interactions.
* @param {Object} event Scope event object passed by the calendar.
* @param {String} action Action, corresponding to the key that was pressed.
*/
CalendarYearCtrl.prototype.handleKeyEvent = function(event, action) {
var self = this;
var calendarCtrl = self.calendarCtrl;
var displayDate = calendarCtrl.displayDate;
if (action === 'select') {
self.changeDate(displayDate).then(function() {
self.onTimestampSelected(displayDate);
});
} else {
var date = null;
var dateUtil = self.dateUtil;
switch (action) {
case 'move-right': date = dateUtil.incrementMonths(displayDate, 1); break;
case 'move-left': date = dateUtil.incrementMonths(displayDate, -1); break;
case 'move-row-down': date = dateUtil.incrementMonths(displayDate, 6); break;
case 'move-row-up': date = dateUtil.incrementMonths(displayDate, -6); break;
}
if (date) {
var min = calendarCtrl.minDate ? dateUtil.getFirstDateOfMonth(calendarCtrl.minDate) : null;
var max = calendarCtrl.maxDate ? dateUtil.getFirstDateOfMonth(calendarCtrl.maxDate) : null;
date = dateUtil.getFirstDateOfMonth(self.dateUtil.clampDate(date, min, max));
self.changeDate(date).then(function() {
calendarCtrl.focus(date);
});
}
}
};
/**
* Attaches listeners for the scope events that are broadcast by the calendar.
*/
CalendarYearCtrl.prototype.attachScopeListeners = function() {
var self = this;
self.$scope.$on('md-calendar-parent-changed', function(event, value) {
self.calendarCtrl.changeSelectedDate(value ? self.dateUtil.getFirstDateOfMonth(value) : value);
self.changeDate(value);
});
self.$scope.$on('md-calendar-parent-action', angular.bind(self, self.handleKeyEvent));
};
/**
* Handles the behavior when a date is selected. Depending on the `mode`
* of the calendar, this can either switch back to the calendar view or
* set the model value.
* @param {number} timestamp The selected timestamp.
*/
CalendarYearCtrl.prototype.onTimestampSelected = function(timestamp) {
var calendarCtrl = this.calendarCtrl;
if (calendarCtrl.mode) {
this.$mdUtil.nextTick(function() {
calendarCtrl.setNgModelValue(timestamp);
});
} else {
calendarCtrl.setCurrentView('month', timestamp);
}
};
})();
(function() {
'use strict';
CalendarYearBodyCtrl['$inject'] = ["$element", "$$mdDateUtil", "$mdDateLocale"];
angular.module('material.components.datepicker')
.directive('mdCalendarYearBody', mdCalendarYearDirective);
/**
* Private component, consumed by the md-calendar-year, which separates the DOM construction logic
* and allows for the year view to use md-virtual-repeat.
*/
function mdCalendarYearDirective() {
return {
require: ['^^mdCalendar', '^^mdCalendarYear', 'mdCalendarYearBody'],
scope: { offset: '=mdYearOffset' },
controller: CalendarYearBodyCtrl,
controllerAs: 'mdYearBodyCtrl',
bindToController: true,
link: function(scope, element, attrs, controllers) {
var calendarCtrl = controllers[0];
var yearCtrl = controllers[1];
var yearBodyCtrl = controllers[2];
yearBodyCtrl.calendarCtrl = calendarCtrl;
yearBodyCtrl.yearCtrl = yearCtrl;
scope.$watch(function() { return yearBodyCtrl.offset; }, function(offset) {
if (angular.isNumber(offset)) {
yearBodyCtrl.generateContent();
}
});
}
};
}
/**
* Controller for a single year.
* ngInject @constructor
*/
function CalendarYearBodyCtrl($element, $$mdDateUtil, $mdDateLocale) {
/** @final {!angular.JQLite} */
this.$element = $element;
/** @final */
this.dateUtil = $$mdDateUtil;
/** @final */
this.dateLocale = $mdDateLocale;
/** @type {Object} Reference to the calendar. */
this.calendarCtrl = null;
/** @type {Object} Reference to the year view. */
this.yearCtrl = null;
/**
* Number of months from the start of the month "items" that the currently rendered month
* occurs. Set via angular data binding.
* @type {number}
*/
this.offset = null;
/**
* Date cell to focus after appending the month to the document.
* @type {HTMLElement}
*/
this.focusAfterAppend = null;
}
/** Generate a