UNPKG

mcs-ng-material

Version:

MCS NG-Meterial is based on mcs-web.

1,440 lines (1,210 loc) 114 kB
/*! * 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