diff --git a/src/components/calendar/calendar.js b/src/components/calendar/calendar.js
index f874462dcd8..6aee12893b3 100644
--- a/src/components/calendar/calendar.js
+++ b/src/components/calendar/calendar.js
@@ -6,39 +6,51 @@
* @name material.components.calendar
* @description Calendar
*/
- angular.module('material.components.calendar', ['material.core'])
- .directive('mdCalendar', calendarDirective);
-
- // TODO(jelbourn): internationalize a11y announcements.
-
- // TODO(jelbourn): Update the selected date on [click, tap, enter]
- // TODO(jelbourn): Shown month transition on [swipe, scroll, keyboard, ngModel change]
- // TODO(jelbourn): Introduce free scrolling that works w/ mobile momemtum scrolling (+snapping)
-
- // TODO(jelbourn): Responsive
- // TODO(jelbourn): Themes
- // TODO(jelbourn); inkRipple (need UX input)
+ angular.module('material.components.calendar', [
+ 'material.core', 'material.components.virtualRepeat'
+ ]).directive('mdCalendar', calendarDirective);
+
+ // FUTURE VERSION
+ // TODO(jelbourn): Animated month transition on ng-model change.
+ // TODO(jelbourn): Scroll snapping
+ // TODO(jelbourn): Month headers stick to top when scrolling
+ // TODO(jelbourn): Previous month opacity is lowered when partially scrolled out of view.
+
+ // PRE RELEASE
+ // TODO(jelbourn): Base colors on the theme
+ // TODO(jelbourn): Align style with spec
+ // TODO(jelbourn): read-only state.
+ // TODO(jelbourn): Make sure the *time* on the written date makes sense (probably midnight).
+ // TODO(jelbourn): Date "isComplete" logic
+ // TODO(jelbourn): Apple + up / down == PgDown and PgUp
+ // COULD GO EITHER WAY
+ // TODO(jelbourn): Clicking on the month label opens the month-picker.
// TODO(jelbourn): Minimum and maximum date
- // TODO(jelbourn): Make sure the *time* on the written date makes sense (probably midnight).
- // TODO(jelbourn): Refactor "sections" into separate files.
- // TODO(jelbourn): Horizontal line between months (pending spec finalization)
- // TODO(jelbourn): Alt+down in date input to open calendar
- // TODO(jelbourn): Animations should use `.finally()` instead of `.then()`
- // TODO(jelbourn): improve default date parser in locale provider.
- // TODO(jelbourn): read-only state.
- // TODO(jelbourn): make aria-live element visibly hidden (but still present on the page).
+ // TODO(jelbourn): Define virtual scrolling constants (compactness).
+
+ /**
+ * 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;
function calendarDirective() {
return {
template:
- '
' +
+ '' +
'
' +
- '
' +
- '
' +
+ '
' +
+ '' +
+ '
' +
+
+ '' +
+ '
' +
+ '' +
'
' +
- '' +
- '
',
+ '',
scope: {},
restrict: 'E',
require: ['ngModel', 'mdCalendar'],
@@ -53,27 +65,15 @@
};
}
- /**
- * Catigorization of type of date changes that can occur.
- * @enum {number}
- */
- var DateChangeType = {
- SAME_MONTH: 0,
- NEXT_MONTH: 1,
- PREVIOUS_MONTH: 2,
- DISTANT_FUTURE: 3,
- DISTANT_PAST: 4
- };
-
/** Class applied to the selected date cell/. */
var SELECTED_DATE_CLASS = 'md-calendar-selected-date';
- /** Class applied to the cell for today. */
- var TODAY_CLASS = 'md-calendar-date-today';
-
- /** Next idientifier for calendar instance. */
+ /** Next identifier for calendar instance. */
var nextUniqueId = 0;
+ /** The first renderable date in the virtual-scrolling calendar (for all instances). */
+ var firstRenderableDate = null;
+
/**
* Controller for the mdCalendar component.
* @ngInject @constructor
@@ -81,6 +81,9 @@
function CalendarCtrl($element, $scope, $animate, $q, $mdConstant,
$$mdDateUtil, $$mdDateLocale, $mdInkRipple, $mdUtil) {
+ /** @type {Array} Dummy array-like object for virtual-repeat to iterate over. */
+ this.items = {length: 2000};
+
/** @final {!angular.$animate} */
this.$animate = $animate;
@@ -114,9 +117,16 @@
/** @final {HTMLElement} */
this.ariaLiveElement = $element[0].querySelector('[aria-live]');
+ /** @final {HTMLElement} */
+ this.calendarScroller = $element[0].querySelector('.md-virtual-repeat-scroller');
+
/** @final {Date} */
this.today = new Date();
+ // Set the first renderable date once for all calendar instances.
+ firstRenderableDate =
+ firstRenderableDate || this.dateUtil.incrementMonths(this.today, -this.items.length / 2);
+
/** @final {number} Unique ID for this calendar instance. */
this.id = nextUniqueId++;
@@ -155,13 +165,19 @@
* @this {HTMLTableCellElement} The cell that was clicked.
*/
this.cellClickHandler = function() {
- if (this.dataset.timestamp) {
+ if (this.hasAttribute('data-timestamp')) {
$scope.$apply(function() {
- self.setNgModelValue(new Date(Number(this.dataset.timestamp)));
+ self.setNgModelValue(new Date(Number(this.getAttribute('data-timestamp'))));
}.bind(this)); // The `this` here is the cell element.
}
};
+ // Do a one-time scroll to the selected date once the months have done their initial render.
+ var off = $scope.$on('md-calendar-month-initial-render', function() {
+ self.scrollToMonth(self.selectedDate);
+ off();
+ });
+
this.attachCalendarEventListeners();
// DEBUG
@@ -190,34 +206,52 @@
*/
CalendarCtrl.prototype.buildInitialCalendarDisplay = function() {
this.buildWeekHeader();
+ this.hideVerticalScrollbar();
this.displayDate = this.selectedDate || new Date(Date.now());
- var nextMonth = this.dateUtil.getDateInNextMonth(this.displayDate);
- this.calendarElement.appendChild(this.buildCalendarForMonth(this.displayDate));
- this.calendarElement.appendChild(this.buildCalendarForMonth(nextMonth));
-
this.isInitialized = true;
};
+ /**
+ * Hides the vertical scrollbar on the calendar scroller 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.
+ */
+ CalendarCtrl.prototype.hideVerticalScrollbar = function() {
+ var element = this.$element[0];
+
+ var scrollMask = element.querySelector('.md-calendar-scroll-mask');
+ var scroller = this.calendarScroller;
+
+ var headerWidth = element.querySelector('.md-calendar-day-header').clientWidth;
+ var scrollbarWidth = scroller.offsetWidth - scroller.clientWidth;
+
+ scrollMask.style.width = headerWidth + 'px';
+ scroller.style.width = (headerWidth + scrollbarWidth) + 'px';
+ scroller.style.paddingRight = scrollbarWidth + 'px';
+ };
+
+ /**
+ * Scrolls to the month of the given date.
+ * @param {Date} date
+ */
+ CalendarCtrl.prototype.scrollToMonth = function(date) {
+ if (!this.dateUtil.isValidDate(date)) {
+ return;
+ }
+
+ var monthDistance = this.dateUtil.getMonthDistance(firstRenderableDate, date);
+ this.calendarScroller.scrollTop = monthDistance * TBODY_HEIGHT;
+ };
+
/**
* Attach event listeners for the calendar.
*/
CalendarCtrl.prototype.attachCalendarEventListeners = function() {
- var self = this;
-
// Keyboard interaction.
this.calendarElement.addEventListener('keydown', this.handleKeyEvent.bind(this));
-
- // EXPERIMENT: does this weel event work on all browsers?
- this.calendarElement.addEventListener('wheel', function(event) {
- event.preventDefault();
- self.$scope.$apply(self.$mdUtil.debounce(function() {
- var transitionToDate = event.deltaY > 0 ?
- self.dateUtil.getDateInNextMonth(self.displayDate) :
- self.dateUtil.getDateInPreviousMonth(self.displayDate);
- self.changeDisplayDate(transitionToDate);
- }, 100));
- });
};
/*** User input handling ***/
@@ -230,8 +264,8 @@
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.
+ // 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) {
self.$scope.$emit('md-calendar-escape');
return;
@@ -256,7 +290,7 @@
// Since this is a keyboard interaction, actually give the newly focused date keyboard
// focus after the been brought into view.
self.changeDisplayDate(date).then(function() {
- self.focusDateElement(date);
+ self.focus(date);
});
});
};
@@ -284,7 +318,7 @@
};
/**
- *
+ * Sets the ng-model value for the calendar and emits a change event.
* @param {Date} date
*/
CalendarCtrl.prototype.setNgModelValue = function(date) {
@@ -295,184 +329,26 @@
/**
* Focus the cell corresponding to the given date.
- * @param {Date} date
+ * @param {Date=} opt_date
*/
- CalendarCtrl.prototype.focusDateElement = function(date) {
- var cellId = this.getDateId_(date);
+ CalendarCtrl.prototype.focus = function(opt_date) {
+ var cellId = this.getDateId(opt_date || this.selectedDate);
var cell = this.calendarElement.querySelector('#' + cellId);
- cell.focus();
- };
-
- /** Focus the calendar. */
- CalendarCtrl.prototype.focus = function() {
- this.focusDateElement(this.selectedDate);
+ if (cell) {
+ cell.focus();
+ }
};
/*** Animation ***/
- /**
- * Animates the calendar to the next month.
- * @param date
- * @returns {angular.$q.Promise} The animation promise.
- */
- CalendarCtrl.prototype.animateToNextMonth = function(date) {
- var currentMonth = this.calendarElement.querySelector('tbody');
- var amountToMove = -(currentMonth.clientHeight) + 'px'; // todo: Why is this 2px off (Chrome)?
-
- var newMonthToShow = this.buildCalendarForMonth(this.dateUtil.getDateInNextMonth(date));
- this.calendarElement.appendChild(newMonthToShow);
-
- var animatePromise = this.$animate.animate(angular.element(this.calendarElement),
- {transform: 'translateY(0)'},
- {transform: 'translateY(' + amountToMove + ')'});
-
- var self = this;
- return animatePromise.then(function() {
- self.calendarElement.removeChild(currentMonth);
- self.calendarElement.style.transform = '';
- });
- };
-
-
- /**
- * Animates the calendar to the previous month.
- * @param date
- * @returns {angular.$q.Promise} The animation promise.
- */
- CalendarCtrl.prototype.animateToPreviousMonth = function(date) {
- var displayedMonths = this.calendarElement.querySelectorAll('tbody');
- var currentMonth = displayedMonths[0];
- var nextMonth = displayedMonths[1];
-
- var newMonthToShow = this.buildCalendarForMonth(date);
- this.calendarElement.insertBefore(newMonthToShow, currentMonth);
- var amountToMove = newMonthToShow.clientHeight + 'px';
-
- var animatePromise = this.$animate.animate(angular.element(this.calendarElement),
- {transform: 'translateY(-' + amountToMove + ')'},
- {transform: 'translateY(0)'});
-
- var self = this;
- return animatePromise.then(function() {
- self.calendarElement.removeChild(nextMonth);
- self.calendarElement.style.transform = '';
- });
- };
-
-
- /**
- * Animates the calendar to a date further than one month in the future.
- * @param date
- * @returns {angular.$q.Promise} The animation promise.
- */
- CalendarCtrl.prototype.animateToDistantFuture = function(date) {
- var displayedMonths = this.calendarElement.querySelectorAll('tbody');
- var currentMonth = displayedMonths[0];
- var nextMonth = displayedMonths[1];
-
- var midpointDate = this.dateUtil.getDateMidpoint(this.displayDate, date);
- var midpointMonth = this.buildCalendarForMonth(midpointDate);
- this.calendarElement.appendChild(midpointMonth);
-
- var amountToMove = -(currentMonth.clientHeight +
- nextMonth.clientHeight + midpointMonth.clientHeight) + 'px';
-
- var targetMonth = this.buildCalendarForMonth(date);
- this.calendarElement.appendChild(targetMonth);
-
- var monthAfterTargetMonth = this.buildCalendarForMonth(this.dateUtil.getDateInNextMonth(date));
- this.calendarElement.appendChild(monthAfterTargetMonth);
-
- var animatePromise = this.$animate.animate(angular.element(this.calendarElement),
- {transform: 'translateY(0)'},
- {transform: 'translateY(' + amountToMove + ')'});
-
- var self = this;
- return animatePromise.then(function() {
- self.calendarElement.removeChild(currentMonth);
- self.calendarElement.removeChild(nextMonth);
- self.calendarElement.removeChild(midpointMonth);
- self.calendarElement.style.transform = '';
- });
- };
-
-
- /**
- * Animates the calendar to a date further than one month in the past.
- * @param date
- * @returns {angular.$q.Promise} The animation promise.
- */
- CalendarCtrl.prototype.animateToDistantPast = function(date) {
- var displayedMonths = this.calendarElement.querySelectorAll('tbody');
- var currentMonth = displayedMonths[0];
- var nextMonth = displayedMonths[1];
-
- var midpointDate = this.dateUtil.getDateMidpoint(this.displayDate, date);
- var midpointMonth = this.buildCalendarForMonth(midpointDate);
- this.calendarElement.insertBefore(midpointMonth, currentMonth);
-
- var monthAfterTargetMonth = this.buildCalendarForMonth(this.dateUtil.getDateInNextMonth(date));
- this.calendarElement.insertBefore(monthAfterTargetMonth, midpointMonth);
-
- var targetMonth = this.buildCalendarForMonth(date);
- this.calendarElement.insertBefore(targetMonth, monthAfterTargetMonth);
-
- var amountToMove = -(targetMonth.clientHeight + midpointMonth.clientHeight +
- monthAfterTargetMonth.clientHeight) + 'px';
-
- var animatePromise = this.$animate.animate(angular.element(this.calendarElement),
- {transform: 'translateY(' + amountToMove + ')'},
- {transform: 'translateY(0)'});
-
- var self = this;
- return animatePromise.then(function() {
- self.calendarElement.removeChild(nextMonth);
- self.calendarElement.removeChild(currentMonth);
- self.calendarElement.removeChild(midpointMonth);
- self.calendarElement.style.transform = '';
- });
- };
-
-
/**
* Animates the transition from the calendar's current month to the given month.
* @param date
* @returns {angular.$q.Promise} The animation promise.
*/
CalendarCtrl.prototype.animateDateChange = function(date) {
- var dateChangeType = this.getDateChangeType(date);
- switch (dateChangeType) {
- case DateChangeType.NEXT_MONTH: return this.animateToNextMonth(date);
- case DateChangeType.PREVIOUS_MONTH: return this.animateToPreviousMonth(date);
- case DateChangeType.DISTANT_FUTURE: return this.animateToDistantFuture(date);
- case DateChangeType.DISTANT_PAST: return this.animateToDistantPast(date);
- default: return this.$q.when();
- }
- };
-
- /**
- * Given a date, determines the type of transition that will occur from the currently shown date.
- * @param {Date} date
- * @returns {DateChangeType}
- */
- CalendarCtrl.prototype.getDateChangeType = function(date) {
- if (date && this.displayDate && !this.dateUtil.isSameMonthAndYear(date, this.displayDate)) {
- if (this.dateUtil.isInNextMonth(this.displayDate, date)) {
- return DateChangeType.NEXT_MONTH;
- }
-
- if (this.dateUtil.isInPreviousMonth(this.displayDate, date)) {
- return DateChangeType.PREVIOUS_MONTH;
- }
-
- if (date > this.displayDate) {
- return DateChangeType.DISTANT_FUTURE;
- }
-
- return DateChangeType.DISTANT_PAST;
- }
-
- return DateChangeType.SAME_MONTH;
+ this.scrollToMonth(date);
+ return this.$q.when();
};
@@ -486,13 +362,12 @@
var self = this;
var previousSelectedDate = this.selectedDate;
this.selectedDate = date;
-
this.changeDisplayDate(date).then(function() {
// Remove the selected class from the previously selected date, if any.
if (previousSelectedDate) {
var prevDateCell =
- self.calendarElement.querySelector('#' + self.getDateId_(previousSelectedDate));
+ self.calendarElement.querySelector('#' + self.getDateId(previousSelectedDate));
if (prevDateCell) {
prevDateCell.classList.remove(SELECTED_DATE_CLASS);
}
@@ -500,7 +375,7 @@
// Apply the select class to the new selected date if it is set.
if (date) {
- var dateCell = self.calendarElement.querySelector('#' + self.getDateId_(date));
+ var dateCell = self.calendarElement.querySelector('#' + self.getDateId(date));
if (dateCell) {
dateCell.classList.add(SELECTED_DATE_CLASS);
}
@@ -527,10 +402,9 @@
return this.$q.when();
}
-
// WORK IN PROGRESS: do nothing if animation is in progress.
if (this.isMonthTransitionInProgress) {
- //return this.$q.when();
+ return this.$q.when();
}
this.isMonthTransitionInProgress = true;
@@ -541,52 +415,34 @@
var self = this;
animationPromise.then(function() {
- self.highlightToday();
self.isMonthTransitionInProgress = false;
});
return animationPromise;
};
-
- /**
- * Highlight the cell corresponding to today if it is on the screen.
- */
- CalendarCtrl.prototype.highlightToday = function() {
- var todayCell = this.calendarElement.querySelector('#' + this.getDateId_(this.today));
- if (todayCell) {
- todayCell.classList.add(TODAY_CLASS);
- }
- };
-
-
/**
* Announces a change in date to the calendar's aria-live region.
* @param {Date} previousDate
* @param {Date} currentDate
*/
CalendarCtrl.prototype.announceDisplayDateChange = function(previousDate, currentDate) {
- // PROOF OF CONCEPT: this obviously needs to be internationalized, but we can see if the idea
- // works.
-
// If the date has not changed at all, do nothing.
if (previousDate && this.dateUtil.isSameDay(previousDate, currentDate)) {
return;
}
- var annoucement = '';
-
- if (!previousDate || !this.dateUtil.isSameMonthAndYear(previousDate, currentDate)) {
- annoucement += currentDate.getFullYear() +
- '. ' +
- this.dateLocale.months[currentDate.getMonth()] + '. ';
+ // If the date has changed to another date within the same month and year, make a short
+ // announcement.
+ if (previousDate && !this.dateUtil.isSameMonthAndYear(previousDate, currentDate)) {
+ this.ariaLiveElement.textContent = this.dateLocale.shortAnnounceFormatter(currentDate);
}
- if (previousDate.getDate() !== currentDate.getDate()) {
- annoucement += this.dateLocale.days[currentDate.getDay()] + '. ' + currentDate.getDate() ;
+ // If the date has changed to another date in a different month and/or year, make a long
+ // announcement.
+ if (!previousDate || previousDate.getDate() !== currentDate.getDate()) {
+ this.ariaLiveElement.textContent = this.dateLocale.longAnnounceFormatter(currentDate);
}
-
- this.ariaLiveElement.textContent = annoucement;
};
@@ -607,114 +463,13 @@
this.$element.find('thead').append(row);
};
- /**
- * 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}
- */
- CalendarCtrl.prototype.buildDateCell = function(opt_date) {
- var cell = document.createElement('td');
- cell.classList.add('md-calendar-date');
-
- if (opt_date) {
- // Add a indicator for select, hover, and focus states.
- var selectionIndicator = document.createElement('span');
- cell.appendChild(selectionIndicator);
- selectionIndicator.classList.add('md-calendar-date-selection-indicator');
- selectionIndicator.textContent = this.dateLocale.dates[opt_date.getDate()];
- //selectionIndicator.setAttribute('aria-label', '');
-
- cell.setAttribute('tabindex', '-1');
- cell.id = this.getDateId_(opt_date);
- cell.dataset.timestamp = opt_date.getTime();
- cell.addEventListener('click', this.cellClickHandler);
- }
-
- return cell;
- };
-
-
- /**
- * Builds a element for the given date's month.
- * @param {Date=} opt_dateInMonth
- * @returns {HTMLTableSectionElement} A containing the
elements.
- */
- CalendarCtrl.prototype.buildCalendarForMonth = function(opt_dateInMonth) {
- var date = opt_dateInMonth || new Date();
-
- var firstDayOfMonth = this.dateUtil.getFirstDateOfMonth(date);
- var firstDayOfTheWeek = firstDayOfMonth.getDay();
- 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.createElement('tbody');
- monthBody.classList.add('md-calendar-month');
- monthBody.setAttribute('aria-hidden', 'true');
-
- var row = document.createElement('tr');
- monthBody.appendChild(row);
-
- // Add a label for the month. If the month starts on a Sunday or a Monday, 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');
- monthLabelCell.classList.add('md-calendar-month-label');
- if (firstDayOfTheWeek <= 1) {
- monthLabelCell.setAttribute('colspan', '7');
- monthLabelCell.textContent = this.dateLocale.shortMonths[date.getMonth()];
-
- var monthLabelRow = document.createElement('tr');
- monthLabelRow.appendChild(monthLabelCell);
- monthBody.insertBefore(monthLabelRow, row);
- } else {
- blankCellOffset = 2;
- monthLabelCell.setAttribute('colspan', '2');
- monthLabelCell.textContent = this.dateLocale.shortMonths[date.getMonth()];
-
- 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) {
- dayOfWeek = 0;
- row = document.createElement('tr');
- monthBody.appendChild(row);
- }
-
- iterationDate.setDate(d);
- var cell = this.buildDateCell(iterationDate);
- row.appendChild(cell);
-
- dayOfWeek++;
- }
-
- return monthBody;
- };
-
-
- /**
+ /**
* Gets an identifier for a date unique to the calendar instance for internal
* purposes. Not to be displayed.
* @param {Date} date
* @returns {string}
- * @private
*/
- CalendarCtrl.prototype.getDateId_ = function(date) {
+ CalendarCtrl.prototype.getDateId = function(date) {
return [
'md',
this.id,
diff --git a/src/components/calendar/calendar.scss b/src/components/calendar/calendar.scss
index 8a8de6ca682..041b8fbe013 100644
--- a/src/components/calendar/calendar.scss
+++ b/src/components/calendar/calendar.scss
@@ -1,29 +1,91 @@
// Styles for mdCalendar.
-$date-cell-size: 44px !default;
-$date-cell-emphasis-size: 40px !default;
-$calendar-number-of-weeks: 7 !default;
-
-// Style applied to date cells, including day-of-the-week header cells.
-@mixin calendar-date-cell() {
- height: $date-cell-size;
- width: $date-cell-size;
+$md-calendar-cell-size: 44px !default;
+$md-calendar-header-height: 40px;
+$md-calendar-cell-emphasis-size: 40px !default;
+$md-calendar-side-padding: 16px !default;
+$md-calendar-weeks-to-show: 7 !default;
+
+$md-calendar-month-label-padding: 8px !default;
+$md-calendar-month-label-font-size: 13px !default;
+
+$md-calendar-width: (7 * $md-calendar-cell-size) + (2 * $md-calendar-side-padding);
+$md-calendar-height:
+ ($md-calendar-weeks-to-show * $md-calendar-cell-size) + $md-calendar-header-height;
+
+
+// Styles for date cells, including day-of-the-week header cells.
+@mixin md-calendar-cell() {
+ height: $md-calendar-cell-size;
+ width: $md-calendar-cell-size;
+
text-align: center;
+
+ // Remove all padding and borders so we can completely
+ // control the size of the table cells.
+ padding: 0;
+ border: none;
+
+ // The left / right padding is applied to the cells instead of the wrapper
+ // because we want the header background and the month dividing border to
+ // extend the entire width of the calendar.
+ &:first-child {
+ padding-left: $md-calendar-side-padding;
+ }
+
+ &:last-child {
+ padding-right: $md-calendar-side-padding;
+ }
+}
+
+// Styles for tables used in mdCalendar (the day-of-the-week header and the table of dates itself).
+@mixin md-calendar-table() {
+ // Fixed table layout makes IE faster.
+ // https://msdn.microsoft.com/en-us/library/ms533020(VS.85).aspx
+ table-layout: fixed;
+ border-spacing: 0;
+ border-collapse: collapse;
}
md-calendar {
- font-size: 12px;
+ font-size: 13px;
+ user-select: none;
}
-.md-calendar-container {
- position: relative;
- max-height: $calendar-number-of-weeks * $date-cell-size;
+// Wrap the scroll with overflow: hidden in order to hide the scrollbar.
+// The inner .md-calendar-scroller will using a padding-right to push the
+// scrollbar into the hidden area (done with javascript).
+.md-calendar-scroll-mask {
+ display: inline-block;
overflow: hidden;
+ height: $md-calendar-weeks-to-show * $md-calendar-cell-size;
+
+ .md-virtual-repeat-scroller {
+ overflow-y: scroll;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .md-virtual-repeat-offsetter {
+ width: 100%;
+ }
+}
+
+// Scrolling element (the md-virtual-repeat-container).
+.md-calendar-scroller {
+ display: inline-block;
+ height: $md-calendar-weeks-to-show * $md-calendar-cell-size;
+ width: $md-calendar-width;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
}
+// A single date cell in the calendar table.
.md-calendar-date {
- @include calendar-date-cell();
+ @include md-calendar-cell();
}
+// Element inside of every date cell that can indicate that the date is selected.
.md-calendar-date-selection-indicator {
transition-property: background-color, color;
transition-duration: $swift-ease-out-duration;
@@ -34,25 +96,44 @@ md-calendar {
cursor: pointer;
- width: $date-cell-emphasis-size;
- height: $date-cell-emphasis-size;
- line-height: $date-cell-emphasis-size;
+ width: $md-calendar-cell-emphasis-size;
+ height: $md-calendar-cell-emphasis-size;
+ line-height: $md-calendar-cell-emphasis-size;
}
+// The label above each month (containing the month name and the year, e.g. "Jun 2014").
.md-calendar-month-label {
- height: $date-cell-size;
+ height: $md-calendar-cell-size;
+ font-size: $md-calendar-month-label-font-size;
+ padding: 0 0 0 $md-calendar-side-padding + $md-calendar-month-label-padding;
}
-.md-calendar-day-header th {
- @include calendar-date-cell();
- font-weight: normal;
+// Table containing the day-of-the-week header.
+.md-calendar-day-header {
+ @include md-calendar-table();
+
+ th {
+ @include md-calendar-cell();
+ font-weight: normal;
+ height: $md-calendar-header-height;
+ }
}
+// Primary table containing all date cells. Each month is a tbody in this table.
.md-calendar {
- // DEBUGGING: add border to container
- border: 1px dotted lightgray;
-}
+ @include md-calendar-table();
+ color: rgba(black, 0.7); // secondary
-.md-calendar.ng-animate {
- transition: transform $swift-ease-in-out-duration $swift-ease-in-out-timing-function;
+ // Divider between months.
+ tr:last-child td {
+ border-bottom: 1px solid #e9e9e9;
+ }
+
+ // The divider between months doesn't actualyl change the height of the tbody in which the
+ // border appear; it changes the height of the following tbody. The causes the first-child to be
+ // 1px shorter than the other months. We fix this by adding an invisible border-top.
+ &:first-child {
+ border-top: 1px solid transparent;
+ }
}
+
diff --git a/src/components/calendar/calendar.spec.js b/src/components/calendar/calendar.spec.js
index 0235171c671..f8bf86e3256 100644
--- a/src/components/calendar/calendar.spec.js
+++ b/src/components/calendar/calendar.spec.js
@@ -5,8 +5,8 @@ describe('md-calendar', function() {
var JAN = 0, FEB = 1, MAR = 2, APR = 3, MAY = 4, JUN = 5, JUL = 6, AUG = 7, SEP = 8, OCT = 9,
NOV = 10, DEC = 11;
- var ngElement, element, scope, pageScope, controller, $animate, $compile;
- var $rootScope, dateLocale;
+ var ngElement, element, scope, pageScope, controller, $animate, $compile, $$rAF;
+ var $rootScope, dateLocale, $mdUtil;
/**
* To apply a change in the date, a scope $apply() AND a manual triggering of animation
@@ -15,11 +15,16 @@ describe('md-calendar', function() {
function applyDateChange() {
pageScope.$apply();
$animate.triggerCallbacks();
+ $$rAF.flush();
+
+ // Internally, the calendar sets scrollTop to scroll to the month for a change.
+ // The handler for that scroll won't be invoked unless we manually trigger it.
+ if (controller) {
+ angular.element(controller.calendarScroller).triggerHandler('scroll');
+ }
}
- /**
- * Extracts text as an array (one element per cell) from a tr element.
- */
+ /** Extracts text as an array (one element per cell) from a tr element. */
function extractRowText(tr) {
var cellContents = [];
angular.forEach(tr.children, function(tableElement) {
@@ -29,9 +34,7 @@ describe('md-calendar', function() {
return cellContents;
}
- /**
- * Finds a date td given a day of the month from an .md-calendar-month element.
- */
+ /** Finds a date td given a day of the month from an .md-calendar-month element. */
function findDateElement(monthElement, day) {
var tds = monthElement.querySelectorAll('td');
var td;
@@ -45,14 +48,14 @@ describe('md-calendar', function() {
}
- /**
- * Creates and compiles an md-calendar element.
- */
+ /** Creates and compiles an md-calendar element. */
function createElement(parentScope) {
var directiveScope = parentScope || $rootScope.$new();
var template = '';
- var newElement = $compile(template)(directiveScope);
- directiveScope.$apply();
+ var attachedElement = angular.element(template);
+ document.body.appendChild(attachedElement[0]);
+ var newElement = $compile(attachedElement)(directiveScope);
+ applyDateChange();
return newElement;
}
@@ -62,7 +65,9 @@ describe('md-calendar', function() {
$animate = $injector.get('$animate');
$compile = $injector.get('$compile');
$rootScope = $injector.get('$rootScope');
+ $$rAF = $injector.get('$$rAF');
dateLocale = $injector.get('$$mdDateLocale');
+ $mdUtil = $injector.get('$mdUtil');
pageScope = $rootScope.$new();
pageScope.myDate = null;
@@ -73,16 +78,22 @@ describe('md-calendar', function() {
controller = ngElement.controller('mdCalendar');
}));
+ afterEach(function() {
+ ngElement.remove();
+ });
+
describe('ngModel binding', function() {
it('should update the calendar based on ngModel change', function() {
pageScope.myDate = new Date(2014, MAY, 30);
+
applyDateChange();
- var displayedMonth = element.querySelector('.md-calendar-month-label');
var selectedDate = element.querySelector('.md-calendar-selected-date');
+ var displayedMonth =
+ $mdUtil.getClosest(selectedDate, 'tbody').querySelector('.md-calendar-month-label');
- expect(displayedMonth.textContent).toBe('May');
+ expect(displayedMonth.textContent).toBe('May 2014');
expect(selectedDate.textContent).toBe('30');
});
@@ -109,9 +120,16 @@ describe('md-calendar', function() {
});
describe('#buildCalendarForMonth', function() {
+ var monthCtrl;
+
+ beforeEach(function() {
+ monthCtrl = angular.element(element.querySelector('[md-calendar-month]'))
+ .controller('mdCalendarMonth');
+ });
+
it('should render a month correctly as a table', function() {
var date = new Date(2014, MAY, 30);
- var monthElement = controller.buildCalendarForMonth(date);
+ var monthElement = monthCtrl.buildCalendarForMonth(date);
var calendarRows = monthElement.querySelectorAll('tr');
var calendarDates = [];
@@ -121,21 +139,22 @@ describe('md-calendar', function() {
});
var expectedDates = [
- ['May', '', '', '1', '2', '3'],
+ ['May 2014', '', '', '1', '2', '3'],
['4', '5', '6', '7', '8', '9', '10'],
['11', '12', '13', '14', '15', '16', '17'],
['18', '19', '20', '21', '22', '23', '24'],
['25', '26', '27', '28', '29', '30', '31'],
+ ['', '', '', '', '', '', ''],
];
expect(calendarDates).toEqual(expectedDates);
});
it('should show the month on its own row if the first day is before Tuesday', function() {
var date = new Date(2014, JUN, 30); // 1st on Sunday
- var monthElement = controller.buildCalendarForMonth(date);
+ var monthElement = monthCtrl.buildCalendarForMonth(date);
var firstRow = monthElement.querySelector('tr');
- expect(extractRowText(firstRow)).toEqual(['Jun']);
+ expect(extractRowText(firstRow)).toEqual(['Jun 2014']);
});
});
@@ -144,11 +163,9 @@ describe('md-calendar', function() {
pageScope.myDate = controller.today;
applyDateChange();
- var monthElement = element.querySelector('.md-calendar-month');
- var day = controller.today.getDate();
-
- var dateElement = findDateElement(monthElement, day);
- expect(dateElement.classList.contains('md-calendar-date-today')).toBe(true);
+ var todayElement = element.querySelector('.md-calendar-date-today');
+ expect(todayElement).not.toBeNull();
+ expect(todayElement.textContent).toBe(controller.today.getDate() + '');
});
it('should have ids for date elements unique to the directive instance', function() {
diff --git a/src/components/calendar/calendarMonth.js b/src/components/calendar/calendarMonth.js
new file mode 100644
index 00000000000..a8da4a8d087
--- /dev/null
+++ b/src/components/calendar/calendarMonth.js
@@ -0,0 +1,199 @@
+(function() {
+ 'use strict';
+
+
+ angular.module('material.components.calendar')
+ .directive('mdCalendarMonth', mdCalendarMonthDirective);
+
+
+ /**
+ * Private directive consumed by md-calendar. 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.
+ */
+ function mdCalendarMonthDirective() {
+ return {
+ require: ['^^mdCalendar', 'mdCalendarMonth'],
+ scope: {offset: '=mdMonthOffset'},
+ controller: CalendarMonthCtrl,
+ controllerAs: 'mdMonthCtrl',
+ bindToController: true,
+ link: function(scope, element, attrs, controllers) {
+ var calendarCtrl = controllers[0];
+ var monthCtrl = controllers[1];
+
+ monthCtrl.calendarCtrl = calendarCtrl;
+ monthCtrl.generateContent();
+
+ // Emit an event to let the parent md-calendar know that initial render has happened.
+ scope.$emit('md-calendar-month-initial-render');
+
+ // 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 updataed.
+ // Since the months are not generated by bindings, we simply regenerate the entire thing
+ // when the binding (offset) changes.
+ scope.$watch(function() { return monthCtrl.offset }, function(offset, oldOffset) {
+ if (offset != oldOffset) {
+ monthCtrl.generateContent();
+ }
+ });
+ }
+ };
+ }
+
+ /** Class applied to the cell for today. */
+ var TODAY_CLASS = 'md-calendar-date-today';
+
+ /** Class applied to the selected date cell/. */
+ var SELECTED_DATE_CLASS = 'md-calendar-selected-date';
+
+ /**
+ * Controller for a single calendar month.
+ * @ngInject @constructor
+ */
+ function CalendarMonthCtrl($element, $$mdDateUtil, $$mdDateLocale) {
+ this.dateUtil = $$mdDateUtil;
+ this.dateLocale = $$mdDateLocale;
+ this.$element = $element;
+ this.calendarCtrl = null;
+
+ /**
+ * Number of months from the start of the month "items"
+ * that the currently rendered month occurs.
+ * @type {number}
+ */
+ this.offset = 0;
+ }
+
+ /** Generate and append the content for this month to the directive element. */
+ CalendarMonthCtrl.prototype.generateContent = function() {
+ var offset = (-this.calendarCtrl.items.length / 2) + this.offset;
+ var date = this.dateUtil.incrementMonths(this.calendarCtrl.today, offset);
+ this.$element.empty();
+ this.$element.append(this.buildCalendarForMonth(date));
+ };
+
+ /**
+ * 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}
+ */
+ CalendarMonthCtrl.prototype.buildDateCell = function(opt_date) {
+ // TODO(jelbourn): cloneNode is likely a faster way of doing this.
+ var cell = document.createElement('td');
+ cell.classList.add('md-calendar-date');
+
+ if (opt_date) {
+ // Add a indicator for select, hover, and focus states.
+ var selectionIndicator = document.createElement('span');
+ cell.appendChild(selectionIndicator);
+ selectionIndicator.classList.add('md-calendar-date-selection-indicator');
+ selectionIndicator.textContent = this.dateLocale.dates[opt_date.getDate()];
+
+ cell.setAttribute('tabindex', '-1');
+ cell.id = this.calendarCtrl.getDateId(opt_date);
+ cell.addEventListener('click', this.calendarCtrl.cellClickHandler);
+
+ // 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, this.calendarCtrl.today)) {
+ cell.classList.add(TODAY_CLASS);
+ }
+
+ if (this.dateUtil.isValidDate(this.calendarCtrl.selectedDate) &&
+ this.dateUtil.isSameDay(opt_date, this.calendarCtrl.selectedDate)) {
+ cell.classList.add(SELECTED_DATE_CLASS);
+ }
+ }
+
+ return cell;
+ };
+
+ /**
+ * Builds the
content for the given date's month.
+ * @param {Date=} opt_dateInMonth
+ * @returns {DocumentFragment} A document fragment containing the
elements.
+ */
+ CalendarMonthCtrl.prototype.buildCalendarForMonth = function(opt_dateInMonth) {
+ var date = this.dateUtil.isValidDate(opt_dateInMonth) ? opt_dateInMonth : new Date();
+
+ var firstDayOfMonth = this.dateUtil.getFirstDateOfMonth(date);
+ var firstDayOfTheWeek = firstDayOfMonth.getDay();
+ 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 row = document.createElement('tr');
+ monthBody.appendChild(row);
+
+ // 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');
+ monthLabelCell.classList.add('md-calendar-month-label');
+ if (firstDayOfTheWeek <= 2) {
+ monthLabelCell.setAttribute('colspan', '7');
+
+ var monthLabelRow = document.createElement('tr');
+ monthLabelRow.appendChild(monthLabelCell);
+ monthBody.insertBefore(monthLabelRow, row);
+ } else {
+ blankCellOffset = 2;
+ monthLabelCell.setAttribute('colspan', '2');
+ row.appendChild(monthLabelCell);
+ }
+
+ monthLabelCell.textContent = this.dateLocale.monthHeaderFormatter(date);
+
+ // 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) {
+ dayOfWeek = 0;
+ row = document.createElement('tr');
+ 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 = document.createElement('tr');
+ for (var i = 0; i < 7; i++) {
+ whitespaceRow.appendChild(this.buildDateCell());
+ }
+ monthBody.appendChild(whitespaceRow);
+ }
+
+ return monthBody;
+ };
+
+})();
diff --git a/src/components/calendar/dateLocaleProvider.js b/src/components/calendar/dateLocaleProvider.js
index 1c5e3cf7d6e..cf765007913 100644
--- a/src/components/calendar/dateLocaleProvider.js
+++ b/src/components/calendar/dateLocaleProvider.js
@@ -27,7 +27,7 @@
/**
* Function that converts the date portion of a Date to a string.
- * @type {function(Date): string)}
+ * @type {(function(Date): string)}
*/
this.formatDate = null;
@@ -36,6 +36,26 @@
* @type {function(string): Date}
*/
this.parseDate = null;
+
+ /**
+ * Function that formats a Date into a month header string.
+ * @type {function(Date): string}
+ */
+ this.monthHeaderFormatter = null;
+
+ /**
+ * Function that formats a date into a short aria-live announcement that is read when
+ * the focused date changes within the same month.
+ * @type {function(Date): string}
+ */
+ this.shortAnnounceFormatter = null;
+
+ /**
+ * Function that formats a date into a long aria-live announcement that is read when
+ * the focused date changes to a date in a different month.
+ * @type {function(Date): string}
+ */
+ this.longAnnounceFormatter = null;
}
/**
@@ -47,7 +67,7 @@
DateLocaleProvider.prototype.$get = function($locale) {
/**
* Default date-to-string formatting function.
- * @param {Date} date
+ * @param {!Date} date
* @returns {string}
*/
function defaultFormatDate(date) {
@@ -57,12 +77,46 @@
/**
* Default string-to-date parsing function.
* @param {string} dateString
- * @returns {Date}
+ * @returns {!Date}
*/
function defaultParseDate(dateString) {
return new Date(dateString);
}
+ /**
+ * Default date-to-string formatter to get a month header.
+ * @param {!Date} date
+ * @returns {string}
+ */
+ function defaultMonthHeaderFormatter(date) {
+ return service.shortMonths[date.getMonth()] + ' ' + date.getFullYear();
+ }
+
+ /**
+ * Default formatter for short aria-live announcements.
+ * @param {!Date} date
+ * @returns {string}
+ */
+ function defaultShortAnnounceFormatter(date) {
+ // Example: 'Tuesday 12'
+ return service.days[date.getDay()] + ' ' + service.dates[date.getDate()];
+ }
+
+ /**
+ * Default formatter for long aria-live announcements.
+ * @param {!Date} date
+ * @returns {string}
+ */
+ function defaultLongAnnounceFormatter(date) {
+ // Example: '2015 June Thursday 18'
+ return [
+ date.getFullYear(),
+ service.months[date.getMonth()],
+ service.days[date.getDay()],
+ service.dates[date.getDate()]
+ ].join(' ');
+ }
+
// The default "short" day strings are the first character of each day,
// e.g., "Monday" => "M".
var defaultShortDays = $locale.DATETIME_FORMATS.DAY.map(function(day) {
@@ -75,18 +129,21 @@
defaultDates[i] = i;
}
- window.$locale = $locale;
-
// TODO(jelbourn): Freeze this object.
- return {
+ var service = {
months: this.months || $locale.DATETIME_FORMATS.MONTH,
shortMonths: this.shortMonths || $locale.DATETIME_FORMATS.SHORTMONTH,
days: this.days || $locale.DATETIME_FORMATS.DAY,
shortDays: this.shortDays || defaultShortDays,
dates: this.dates || defaultDates,
formatDate: this.formatDate || defaultFormatDate,
- parseDate: this.parseDate || defaultParseDate
+ parseDate: this.parseDate || defaultParseDate,
+ monthHeaderFormatter: this.monthHeaderFormatter || defaultMonthHeaderFormatter,
+ shortAnnounceFormatter: this.shortAnnounceFormatter || defaultShortAnnounceFormatter,
+ longAnnounceFormatter: this.longAnnounceFormatter || defaultLongAnnounceFormatter
};
+
+ return service;
};
$provide.provider('$$mdDateLocale', new DateLocaleProvider());
diff --git a/src/components/calendar/datePicker-theme.scss b/src/components/calendar/datePicker-theme.scss
new file mode 100644
index 00000000000..29fe21f9e80
--- /dev/null
+++ b/src/components/calendar/datePicker-theme.scss
@@ -0,0 +1,6 @@
+.md-date-picker-root.md-THEME_NAME-theme {
+ background: white;
+ &[disabled] {
+ background: '{{background-100}}';
+ }
+}
diff --git a/src/components/calendar/datePicker.js b/src/components/calendar/datePicker.js
index f17b1dd7d8c..377023cf77f 100644
--- a/src/components/calendar/datePicker.js
+++ b/src/components/calendar/datePicker.js
@@ -1,9 +1,20 @@
(function() {
'use strict';
- // TODO(jelbourn): md-calendar shown in floating panel.
- // TODO(jelbourn): little calendar icon next to input
- // TODO(jelbourn): only one open md-calendar panel at a time per application
+ // PRE RELEASE
+ // TODO(jelbourn): aria attributes tying together date input and floating calendar.
+ // TODO(jelbourn): actual calendar icon next to input
+ // TODO(jelbourn): something for mobile (probably calendar panel should take up entire screen)
+ // TODO(jelbourn): style to match specification
+ // TODO(jelbourn): make sure this plays well with validation and ngMessages.
+ // TODO(jelbourn): forward more attributes to the internal input (required, autofocus, etc.)
+ // TODO(jelbourn): floating panel open animation (see animation for menu in spec).
+ // TODO(jelbourn): error state
+
+ // FUTURE VERSION
+ // TODO(jelbourn): input behavior (masking? auto-complete?)
+ // TODO(jelbourn): UTC mode
+ // TODO(jelbourn): RTL
angular.module('material.components.calendar')
@@ -12,11 +23,21 @@
function datePickerDirective() {
return {
template:
- '' +
+ '📅' +
+ '
' +
+ '' +
+ '
' +
+
+ // This pane (and its shadow) will be detached from here and re-attached to the
+ // document body.
'
' +
'' +
- '
',
- //
+ '' +
+
+ // We have a separate shadow element in order to wrap both the floating pane and the
+ // inline input / trigger as one shadowed whole.
+ '',
require: ['ngModel', 'mdDatePicker'],
scope: {},
controller: DatePickerCtrl,
@@ -26,6 +47,9 @@
var mdDatePickerCtrl = controllers[1];
mdDatePickerCtrl.configureNgModel(ngModelCtrl);
+
+ // DEBUG
+ window.dCtrl = mdDatePickerCtrl;
}
};
}
@@ -61,11 +85,14 @@
/** @type {HTMLInputElement} */
this.inputElement = $element[0].querySelector('input');
- /** @type {HTMLElement} Floating calendar pane (instantiated lazily) */
+ /** @type {HTMLElement} */
+ this.inputContainer = $element[0].querySelector('.md-datepicker-input-container');
+
+ /** @type {HTMLElement} Floating calendar pane. */
this.calendarPane = $element[0].querySelector('.md-date-calendar-pane');
- /** @type {Date} */
- this.date = null;
+ /** @type {HTMLElement} Shadow for floating calendar pane and input trigger. */
+ this.calendarShadow = $element[0].querySelector('.md-date-calendar-pane-shadow');
/** @final {!angular.JQLite} */
this.$element = $element;
@@ -73,12 +100,20 @@
/** @final {!angular.Scope} */
this.$scope = $scope;
+ /** @type {Date} */
+ this.date = null;
+
+ /** @type {boolean} */
+ this.isDisabled;
+ this.setDisabled($element[0].disabled);
+
/** @type {boolean} Whether the date-picker's calendar pane is open. */
this.isCalendarOpen = false;
/** Pre-bound click handler is saved so that the event listener can be removed. */
this.bodyClickHandler = this.handleBodyClick.bind(this);
+ this.installPropertyInterceptors();
this.attachChangeListeners();
this.attachInterationListeners();
@@ -116,10 +151,14 @@
self.closeCalendarPane();
});
- // TODO(jelbourn): debounce
+ // TODO(jelbourn): Debounce this input event.
self.inputElement.addEventListener('input', function() {
- var parsedDate = self.dateLocale.parseDate(self.inputElement.value);
+ var inputString = self.inputElement.value;
+ var parsedDate = self.dateLocale.parseDate(inputString);
if (self.dateUtil.isValidDate(parsedDate)) {
+ // TODO(jelbourn): if we can detect here that `inputString` is a "complete" date,
+ // set the ng-model value.
+
self.date = parsedDate;
self.$scope.$apply();
}
@@ -145,20 +184,54 @@
});
};
+ /** Capture properties set to the date-picker and imperitively handle internal changes. */
+ DatePickerCtrl.prototype.installPropertyInterceptors = function() {
+ var self = this;
+
+ // Intercept disabled on the date-picker element to disable the internal input.
+ // This avoids two bindings (outer scope to ctrl, ctrl to input).
+ Object.defineProperty(this.$element[0], 'disabled', {
+ get: function() { return self.isDisabled; },
+ set: function(value) { self.setDisabled(value) }
+ });
+ };
+
+ /**
+ * Sets whether the date-picker is disabled.
+ * @param {boolean} isDisabled
+ */
+ DatePickerCtrl.prototype.setDisabled = function(isDisabled) {
+ this.isDisabled = isDisabled;
+ this.inputElement.disabled = isDisabled;
+ };
+
/** Position and attach the floating calendar to the document. */
DatePickerCtrl.prototype.attachCalendarPane = function() {
- var elementRect = this.$element[0].getBoundingClientRect();
+ this.inputContainer.classList.add('md-open');
+ var elementRect = this.inputContainer.getBoundingClientRect();
- this.calendarPane.style.left = elementRect.left + 'px';
- this.calendarPane.style.top = elementRect.bottom + 'px';
+ this.calendarPane.style.left = (elementRect.left + window.pageXOffset) + 'px';
+ this.calendarPane.style.top = (elementRect.bottom + window.pageYOffset) + 'px';
document.body.appendChild(this.calendarPane);
+
+ // Add shadow to the calendar pane only after the UI thread has reached idle, allowing the
+ // content of the calender pane to be rendered.
+ this.$timeout(function() {
+ this.calendarShadow.style.top = (elementRect.top + window.pageYOffset) + 'px';
+ this.calendarShadow.style.left = this.calendarPane.style.left;
+ this.calendarShadow.style.height =
+ (this.calendarPane.getBoundingClientRect().bottom - elementRect.top) + 'px';
+ document.body.appendChild(this.calendarShadow);
+ }.bind(this), 0, false);
};
/** Detach the floating calendar pane from the document. */
DatePickerCtrl.prototype.detachCalendarPane = function() {
+ this.inputContainer.classList.remove('md-open');
// Use native DOM removal because we do not want any of the angular state of this element
// to be disposed.
this.calendarPane.parentNode.removeChild(this.calendarPane);
+ this.calendarShadow.parentNode.removeChild(this.calendarShadow);
};
/** Open the floating calendar pane. */
@@ -166,7 +239,6 @@
if (!this.isCalendarOpen) {
this.isCalendarOpen = true;
this.attachCalendarPane();
- // TODO(jelbourn): dispatch to tell other date pickers to close.
this.focusCalendar();
// Attach click listener inside of a timeout because, if this open call was triggered by a
@@ -207,10 +279,13 @@
*/
DatePickerCtrl.prototype.handleBodyClick = function(event) {
if (this.isCalendarOpen) {
+ // TODO(jelbourn): way want to also include the md-datepicker itself in this check.
var isInCalendar = this.$mdUtil.getClosest(event.target, 'md-calendar');
if (!isInCalendar) {
this.closeCalendarPane();
}
+
+ this.$scope.$digest();
}
};
})();
diff --git a/src/components/calendar/datePicker.scss b/src/components/calendar/datePicker.scss
index 7f64a65ba63..c995db9b659 100644
--- a/src/components/calendar/datePicker.scss
+++ b/src/components/calendar/datePicker.scss
@@ -1,9 +1,68 @@
+$md-datepicker-button-size: 48px;
+$md-datepicker-button-gap: 20px;
+
+@mixin md-flat-input() {
+ font-size: 14px;
+ line-height: 40px;
+
+ box-sizing: border-box;
+ border: none;
+ box-shadow: none;
+ outline: none;
+ background: transparent;
+
+ &::-ms-clear {
+ display: none;
+ }
+}
+
+.md-date-picker-button {
+ margin-right: $md-datepicker-button-gap;
+ height: $md-datepicker-button-size;
+ width: $md-datepicker-button-size;
+ background: none;
+}
+
+.md-datepicker-input {
+ @include md-flat-input();
+ width: 100%;
+ line-height: 21px;
+}
+
+.md-datepicker-input-container {
+ position: relative;
+ z-index: $z-index-datepicker-trigger;
+
+ display: inline-block;
+ border-bottom: 1px solid #e0e0e0;
+ width: 120px;
+
+ &.md-open {
+ border: 1px solid #e0e0e0;
+ border-bottom: none;
+ min-width: $md-calendar-width;
+
+ .md-datepicker-input {
+ margin-left: 24px;
+ line-height: 40px;
+ }
+ }
+}
+
+
.md-date-calendar-pane {
position: absolute;
top: 0;
left: 0;
+ z-index: $z-index-menu;
- // DEBUG
- box-shadow: 0 4px 4px;
+ border: 1px solid #e0e0e0;
+ border-top: none;
background: white;
}
+
+.md-date-calendar-pane-shadow {
+ position: absolute;
+ z-index: $z-index-datepicker-shadow;
+ width: $md-calendar-width;
+}
diff --git a/src/components/calendar/dateUtil.js b/src/components/calendar/dateUtil.js
index 53a2c6ac978..f0441b1776e 100644
--- a/src/components/calendar/dateUtil.js
+++ b/src/components/calendar/dateUtil.js
@@ -20,6 +20,7 @@
incrementMonths: incrementMonths,
getLastDateOfMonth: getLastDateOfMonth,
isSameDay: isSameDay,
+ getMonthDistance: getMonthDistance,
isValidDate: isValidDate
};
@@ -155,6 +156,19 @@
return dateInTargetMonth;
}
+ /**
+ * Get the integer distance between two months. This *only* considers the month and year
+ * portion of the Date instances.
+ *
+ * @param {Date} start
+ * @param {Date} end
+ * @returns {number} Number of months between `start` and `end`. If `end` is before `start`
+ * chronologically, this number will be negative.
+ */
+ function getMonthDistance(start, end) {
+ return (12 * (end.getFullYear() - start.getFullYear())) + (end.getMonth() - start.getMonth());
+ }
+
/**
* Gets the last day of the month for the given date.
* @param {Date} date
diff --git a/src/components/calendar/demoCalendar/index.html b/src/components/calendar/demoCalendar/index.html
index bd9ce4f9486..c9bc00f63e2 100644
--- a/src/components/calendar/demoCalendar/index.html
+++ b/src/components/calendar/demoCalendar/index.html
@@ -11,6 +11,8 @@
Development tools
+
+
diff --git a/src/components/calendar/demoCalendar/style.css b/src/components/calendar/demoCalendar/style.css
index 1156334a7f7..cc979174937 100644
--- a/src/components/calendar/demoCalendar/style.css
+++ b/src/components/calendar/demoCalendar/style.css
@@ -1 +1,5 @@
/** Demo styles for mdCalendar. */
+
+md-calendar {
+ margin: 1px;
+}
diff --git a/src/components/calendar/demoDatePicker/style.css b/src/components/calendar/demoDatePicker/style.css
index 8a4690861dd..1156334a7f7 100644
--- a/src/components/calendar/demoDatePicker/style.css
+++ b/src/components/calendar/demoDatePicker/style.css
@@ -1,4 +1 @@
/** Demo styles for mdCalendar. */
-.md-date-picker {
- border: 2px solid darkred;
-}
diff --git a/src/components/virtualRepeat/virtualRepeater.scss b/src/components/virtualRepeat/virtualRepeater.scss
index 0304021cde1..75925493bf5 100644
--- a/src/components/virtualRepeat/virtualRepeater.scss
+++ b/src/components/virtualRepeat/virtualRepeater.scss
@@ -14,7 +14,8 @@ $virtual-repeat-scrollbar-width: 16px;
left: 0;
margin: 0;
overflow-x: hidden;
- overflow-y: auto;
+ overflow-y: scroll;
+ -webkit-overflow-scrolling: touch;
padding: 0;
position: absolute;
right: 0;
diff --git a/src/core/style/variables.scss b/src/core/style/variables.scss
index 08797bf2494..28ecec9007d 100644
--- a/src/core/style/variables.scss
+++ b/src/core/style/variables.scss
@@ -73,6 +73,10 @@ $z-index-sidenav: 60 !default;
$z-index-backdrop: 50 !default;
$z-index-fab: 20 !default;
+// It is important that datepicker shadow is underneath both the trigger and the floating pane.
+$z-index-datepicker-trigger: 5 !default;
+$z-index-datepicker-shadow: 4 !default;
+
// Easing Curves
//--------------------------------------------