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 //--------------------------------------------