From 9b0b861ef04dbd565bbcf265793ed1a6a28227f1 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Wed, 14 Jan 2015 12:05:07 -0500 Subject: [PATCH] feat(calendar): starting work for date-picker. --- src/components/button/button.scss | 2 + src/components/calendar/calendar-theme.scss | 28 + src/components/calendar/calendar.js | 686 ++++++++++++++++++ src/components/calendar/calendar.scss | 58 ++ src/components/calendar/calendar.spec.js | 63 ++ src/components/calendar/dateLocaleProvider.js | 86 +++ src/components/calendar/dateUtil.js | 166 +++++ src/components/calendar/dateUtil.spec.js | 197 +++++ .../calendar/demoBasicUsage/index.html | 28 + .../calendar/demoBasicUsage/script.js | 12 + .../calendar/demoBasicUsage/style.css | 1 + 11 files changed, 1327 insertions(+) create mode 100644 src/components/calendar/calendar-theme.scss create mode 100644 src/components/calendar/calendar.js create mode 100644 src/components/calendar/calendar.scss create mode 100644 src/components/calendar/calendar.spec.js create mode 100644 src/components/calendar/dateLocaleProvider.js create mode 100644 src/components/calendar/dateUtil.js create mode 100644 src/components/calendar/dateUtil.spec.js create mode 100644 src/components/calendar/demoBasicUsage/index.html create mode 100644 src/components/calendar/demoBasicUsage/script.js create mode 100644 src/components/calendar/demoBasicUsage/style.css diff --git a/src/components/button/button.scss b/src/components/button/button.scss index 9ed379b52ef..28ea1682a04 100644 --- a/src/components/button/button.scss +++ b/src/components/button/button.scss @@ -181,6 +181,7 @@ $icon-button-margin: rem(0.600) !default; } } } + .md-toast-open-bottom { .md-button.md-fab-bottom-left, .md-button.md-fab-bottom-right { @@ -199,6 +200,7 @@ $icon-button-margin: rem(0.600) !default; flex: 1; width: 100%; } + .md-button-group > .md-button { flex: 1; diff --git a/src/components/calendar/calendar-theme.scss b/src/components/calendar/calendar-theme.scss new file mode 100644 index 00000000000..148de3a242a --- /dev/null +++ b/src/components/calendar/calendar-theme.scss @@ -0,0 +1,28 @@ +// Theme styles for mdCalendar. + +.md-calendar-day-header { + background-color: #eeeeee; // grey-200 + color: #616161; // need spec; currently grey-700 +} + +.md-calendar-date.md-calendar-date-today { + color: #2196f3; // blue-500 +} + +.md-calendar-date:focus { + .md-calendar-date-selection-indicator { + background-color: #e0e0e0; // grey-300 + } +} + +.md-calendar-date-selection-indicator:hover { + background-color: #e0e0e0; // grey-300 +} + +// Selected style goes after hover and focus so that it takes priority. +.md-calendar-date.md-calendar-selected-date { + .md-calendar-date-selection-indicator { + background-color: #2196f3; // blue-500 + color: white; // ? + } +} diff --git a/src/components/calendar/calendar.js b/src/components/calendar/calendar.js new file mode 100644 index 00000000000..edcd0c12417 --- /dev/null +++ b/src/components/calendar/calendar.js @@ -0,0 +1,686 @@ +(function() { + 'use strict'; + + /** + * @ngdoc module + * @name material.components.calendar + * @description Calendar + */ + angular.module('material.components.calendar', ['material.core']) + .directive('mdCalendar', calendarDirective); + + // TODO(jelbourn): i18n [month names, day names, days of month, date formatting] + // TODO(jelbourn): Date cell IDs need to be unique per-calendar. + + // TODO(jelbourn): a11y (announcements and labels) + + // 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) + + // 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): Highlight today. + // 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()` + + var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + var fullMonths = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', + 'September', 'October', 'November', 'December']; + var fullDays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + + function calendarDirective() { + return { + template: + '
' + + '' + + '' + + '
SMTWTFS
' + + '
' + + '
' + + '
' + + '
' + + '
', + restrict: 'E', + require: ['ngModel', 'mdCalendar'], + controller: CalendarCtrl, + controllerAs: 'ctrl', + bindToController: true, + link: function(scope, element, attrs, controllers) { + var ngModelCtrl = controllers[0]; + var mdCalendarCtrl = controllers[1]; + mdCalendarCtrl.configureNgModel(ngModelCtrl); + } + }; + } + + /** + * 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 + }; + + // TODO(jelbourn): Refactor this to core and share with other components. + /** @enum {number} */ + var Keys = { + ENTER: 13, + PAGE_UP: 33, + PAGE_DOWN: 34, + END: 35, + HOME: 36, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40 + }; + + /** 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'; + + + /** + * Gets a unique identifier for a date for internal purposes. Not to be displayed. + * @param {Date} date + * @returns {string} + */ + function getDateId(date) { + return 'md-' + date.getFullYear() + '-' + date.getMonth() + '-' + date.getDate(); + } + + /** + * Controller for the mdCalendar component. + * @ngInject @constructor + */ + function CalendarCtrl($element, $scope, $animate, $q, $$mdDateUtil, $$mdDateLocale, $mdInkRipple, $mdUtil) { + /** @final {!angular.$animate} */ + this.$animate = $animate; + + /** @final {!angular.$q} */ + this.$q = $q; + + /** @final */ + this.$mdInkRipple = $mdInkRipple; + + /** @final */ + this.$mdUtil = $mdUtil; + + /** @final */ + this.dateUtil = $$mdDateUtil; + + /** @final {!angular.JQLite} */ + this.$element = $element; + + /** @final {!angular.Scope} */ + this.$scope = $scope; + + /** @final {HTMLElement} */ + this.calendarElement = $element[0].querySelector('.md-calendar'); + + /** @final {HTMLElement} */ + this.ariaLiveElement = $element[0].querySelector('[aria-live]'); + + /** @final {Date} */ + this.today = new Date(); + + /** @type {!angular.NgModelController} */ + this.ngModelCtrl = null; + + /** + * The selected date. Keep track of this separately from the ng-model value so that we + * can know, when the ng-model value changes, what the previous value was before its updated + * in the component's UI. + * + * @type {Date} + */ + this.selectedDate = null; + + /** + * The date that is currently focused or showing in the calendar. This will initially be set + * to the ng-model value if set, otherwise to today. It will be updated as the user navigates + * to other months. The cell corresponding to the displayDate does not necesarily always have + * focus in the document (such as for cases when the user is scrolling the calendar). + * @type {Date} + */ + this.displayDate = null; + + /** @type {boolean} */ + this.isInitialized = false; + + /** @type {boolean} */ + this.isMonthTransitionInProgress = false; + + var self = this; + + /** + * Handles a click event on a date cell. + * Created here so that every cell can use the same function instance. + * @this {HTMLTableCellElement} The cell that was clicked. + */ + this.cellClickHandler = function() { + if (this.dataset.timestamp) { + $scope.$apply(function() { + self.ngModelCtrl.$setViewValue(new Date(Number(this.dataset.timestamp))); + self.ngModelCtrl.$render(); + }.bind(this)); + } + }; + + this.attachCalendarEventListeners(); + + // DEBUG + window.ctrl = this; + } + + + /*** Initialization ***/ + + /** + * Sets up the controller's reference to ngModelController. + * @param {!angular.NgModelController} ngModelCtrl + */ + CalendarCtrl.prototype.configureNgModel = function(ngModelCtrl) { + this.ngModelCtrl = ngModelCtrl; + + var self = this; + ngModelCtrl.$render = function() { + self.changeSelectedDate(self.ngModelCtrl.$viewValue); + }; + }; + + /** + * Initialize the calendar by building the months that are initially visible. + * Initialization should occur after the ngModel value is known. + */ + CalendarCtrl.prototype.buildInitialCalendarDisplay = function() { + this.displayDate = this.selectedDate || new Date(); + var nextMonth = this.dateUtil.getDateInNextMonth(this.displayDate); + this.calendarElement.appendChild(this.buildCalendarForMonth(this.displayDate)); + this.calendarElement.appendChild(this.buildCalendarForMonth(nextMonth)); + + this.isInitialized = true; + }; + + /** + * 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 ***/ + + /** + * Handles a key event in the calendar with the appropriate action. The action will either + * be to select the focused date or to navigate to focus a new date. + * @param {KeyboardEvent} event + */ + CalendarCtrl.prototype.handleKeyEvent = function(event) { + var self = this; + this.$scope.$apply(function() { + // Handled key events fall into two categories: selection and navigation. + // Start by checking if this is a selection event. + if (event.which === Keys.ENTER) { + self.ngModelCtrl.$setViewValue(self.displayDate); + self.ngModelCtrl.$render(); + event.preventDefault(); + return; + } + + // Selection isn't occuring, so the key event is either navigation or nothing. + var date = self.getFocusDateFromKeyEvent(event); + + // Prevent the default on the key event only if it triggered a date navigation. + if (!self.dateUtil.isSameDay(date, self.displayDate)) { + event.preventDefault(); + } + + // 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); + }); + }) + }; + + /** + * Gets the date to focus as the result of a key event. + * @param {KeyboardEvent} event + * @returns {Date} + */ + CalendarCtrl.prototype.getFocusDateFromKeyEvent = function(event) { + var dateUtil = this.dateUtil; + + switch (event.which) { + case Keys.RIGHT: return dateUtil.incrementDays(this.displayDate, 1); + case Keys.LEFT: return dateUtil.incrementDays(this.displayDate, -1); + case Keys.DOWN: return dateUtil.incrementDays(this.displayDate, 7); + case Keys.UP: return dateUtil.incrementDays(this.displayDate, -7); + case Keys.PAGE_DOWN: return dateUtil.incrementMonths(this.displayDate, 1); + case Keys.PAGE_UP: return dateUtil.incrementMonths(this.displayDate, -1); + case Keys.HOME: return dateUtil.getFirstDateOfMonth(this.displayDate); + case Keys.END: return dateUtil.getLastDateOfMonth(this.displayDate); + default: return this.displayDate; + } + }; + + /** + * Focus the cell corresponding to the given date. + * @param {Date} date + */ + CalendarCtrl.prototype.focusDateElement = function(date) { + var cellId = getDateId(date); + var cell = this.calendarElement.querySelector('#' + cellId); + 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; + }; + + + /*** Updating the displayed / selected date ***/ + + /** + * Change the selected date in the calendar (ngModel value has already been changed). + * @param {Date} date + */ + CalendarCtrl.prototype.changeSelectedDate = function(date) { + var self = this; + this.changeDisplayDate(date).then(function() { + + // Remove the selected class from the previously selected date, if any. + if (self.selectedDate) { + var prevDateCell = self.calendarElement.querySelector('#' + getDateId(self.selectedDate)); + if (prevDateCell) { + prevDateCell.classList.remove(SELECTED_DATE_CLASS); + } + } + + // Apply the select class to the new selected date if it is set. + if (date) { + var dateCell = self.calendarElement.querySelector('#' + getDateId(date)); + if (dateCell) { + dateCell.classList.add(SELECTED_DATE_CLASS); + } + } + + self.selectedDate = date; + }); + }; + + + /** + * Change the date that is being shown in the calendar. If the given date is in a different + * month, the displayed month will be transitioned. + * @param {Date} date + */ + CalendarCtrl.prototype.changeDisplayDate = function(date) { + // Initialization is deferred until this function is called because we want to reflect + // the starting value of ngModel. + if (!this.isInitialized) { + this.buildInitialCalendarDisplay(); + return this.$q.when(); + } + + // If trying to show a null or undefined date, do nothing. + if (!date) { + return this.$q.when(); + } + + + // WORK IN PROGRESS: do nothing if animation is in progress. + if (this.isMonthTransitionInProgress) { + //return this.$q.when(); + } + + this.isMonthTransitionInProgress = true; + var animationPromise = this.animateDateChange(date); + + this.announceDisplayDateChange(this.displayDate, date); + this.displayDate = date; + + 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('#' + 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() + '. ' + fullMonths[currentDate.getMonth()] + '. '; + } + + if (previousDate.getDate() !== currentDate.getDate()) { + annoucement += fullDays[currentDate.getDay()] + '. ' + currentDate.getDate() ; + } + + this.ariaLiveElement.textContent = annoucement; + }; + + + /*** Constructing the calendar table ***/ + + /** + * 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 = opt_date.getDate(); + //selectionIndicator.setAttribute('aria-label', ''); + + cell.setAttribute('tabindex', '-1'); + cell.id = 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 = months[date.getMonth()]; + + var monthLabelRow = document.createElement('tr'); + monthLabelRow.appendChild(monthLabelCell); + monthBody.insertBefore(monthLabelRow, row); + } else { + blankCellOffset = 2; + monthLabelCell.setAttribute('colspan', '2'); + monthLabelCell.textContent = months[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; + }; +})(); diff --git a/src/components/calendar/calendar.scss b/src/components/calendar/calendar.scss new file mode 100644 index 00000000000..8a8de6ca682 --- /dev/null +++ b/src/components/calendar/calendar.scss @@ -0,0 +1,58 @@ +// 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; + text-align: center; +} + +md-calendar { + font-size: 12px; +} + +.md-calendar-container { + position: relative; + max-height: $calendar-number-of-weeks * $date-cell-size; + overflow: hidden; +} + +.md-calendar-date { + @include calendar-date-cell(); +} + +.md-calendar-date-selection-indicator { + transition-property: background-color, color; + transition-duration: $swift-ease-out-duration; + transition-timing-function: $swift-ease-out-timing-function; + + border-radius: 50%; + display: inline-block; + + cursor: pointer; + + width: $date-cell-emphasis-size; + height: $date-cell-emphasis-size; + line-height: $date-cell-emphasis-size; +} + +.md-calendar-month-label { + height: $date-cell-size; +} + +.md-calendar-day-header th { + @include calendar-date-cell(); + font-weight: normal; +} + +.md-calendar { + // DEBUGGING: add border to container + border: 1px dotted lightgray; +} + +.md-calendar.ng-animate { + transition: transform $swift-ease-in-out-duration $swift-ease-in-out-timing-function; +} diff --git a/src/components/calendar/calendar.spec.js b/src/components/calendar/calendar.spec.js new file mode 100644 index 00000000000..7944812ad9a --- /dev/null +++ b/src/components/calendar/calendar.spec.js @@ -0,0 +1,63 @@ + +describe('md-checkbox', function() { + var ngElement, element, scope, pageScope, controller, $animate; + + /** + * To apply a change in the date, a scope $apply() AND a manual triggering of animation + * callbacks is necessary. + */ + function applyDateChange() { + pageScope.$apply(); + $animate.triggerCallbacks(); + } + + beforeEach(module('material.components.calendar', 'ngAnimateMock')); + + beforeEach(inject(function($compile, $rootScope, _$animate_) { + $animate = _$animate_; + + var template = ''; + pageScope = $rootScope.$new(); + pageScope.myDate = null; + + ngElement = $compile(template)(pageScope); + element = ngElement[0]; + scope = ngElement.scope(); + controller = ngElement.controller('mdCalendar'); + + pageScope.$apply(); + })); + + describe('ngModel binding', function() { + + it('should update the calendar based on ngModel change', function() { + pageScope.myDate = new Date(2014, 4, 30); + applyDateChange(); + + var displayedMonth = element.querySelector('.md-calendar-month-label'); + var selectedDate = element.querySelector('.md-calendar-selected-date'); + + expect(displayedMonth.textContent).toBe('May'); + expect(selectedDate.textContent).toBe('30') + }); + + }); + + describe('calendar construction', function() { + + }); + + describe('keyboard events', function() { + + }); + + describe('focus behavior', function() { + + }); + + describe('a11y announcements', function() { + }); + + describe('i18n', function() { + }); +}); diff --git a/src/components/calendar/dateLocaleProvider.js b/src/components/calendar/dateLocaleProvider.js new file mode 100644 index 00000000000..c44d5dbb3f8 --- /dev/null +++ b/src/components/calendar/dateLocaleProvider.js @@ -0,0 +1,86 @@ +(function() { + 'use strict'; + + /** + * Provider that allows the user to specify messages, formatters, and parsers for date + * internationalization. + */ + angular.module('material.components.calendar').config(function($provide) { + // TODO(jelbourn): Assert provided values are correctly formatted. Need assertions. + + /** @constructor */ + function DateLocaleProvider() { + /** Array of full month names. E.g., ['January', 'Febuary', ...] */ + this.months = null; + + /** Array of abbreviated month names. E.g., ['Jan', 'Feb', ...] */ + this.shortMonths = null; + + /** Array of full day of the week names. E.g., ['Monday', 'Tuesday', ...] */ + this.days = null; + + /** Array of abbreviated dat of the week names. E.g., ['M', 'T', ...] */ + this.shortDays = null; + + /** Array of dates of a month (1 - 31). Characters might be different in some locales. */ + this.dates = null; + + /** + * Function that converts the date portion of a Date to a string. + * @type {function(Date): string)} + */ + this.formatDate = null; + + /** + * Function that converts a date string to a Date object (the date portion) + * @type {function(string): Date} + */ + this.parseDate = null; + } + + /** + * Factory function that returns an instance of the dateLocale service. + * @ngInject + * @param $locale + * @returns {DateLocale} + */ + DateLocaleProvider.prototype.$get = function($locale, $filter) { + /** + * Default date-to-string formatting function. + * @param {Date} date + * @returns {string} + */ + function defaultFormatDate(date) { + return date.toLocaleDateString(); + } + + /** + * Default string-to-date parsing function. + * @param {string} dateString + * @returns {Date} + */ + function defaultParseDate(dateString) { + return new Date(dateString); + } + + // The default "short" day strings are the first character of each day. + var defaultShortDays = $locale.DATETIME_FORMATS.DAY.map(function(day) { + return day[0]; + }); + + window.$locale = $locale; + window.$filter = $filter; + + var dateLocale = { + 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, + formatDate: defaultFormatDate, + parseDate: defaultParseDate + }; + }; + + $provide.provider('$$mdDateLocale', new DateLocaleProvider()); + }); +})(); diff --git a/src/components/calendar/dateUtil.js b/src/components/calendar/dateUtil.js new file mode 100644 index 00000000000..a161bd772fd --- /dev/null +++ b/src/components/calendar/dateUtil.js @@ -0,0 +1,166 @@ +(function() { + 'use strict'; + + /** + * Utility for performing date calculations to facilitate operation of the calendar and + * datepicker. + */ + angular.module('material.components.calendar').factory('$$mdDateUtil', function() { + return { + getFirstDateOfMonth: getFirstDateOfMonth, + getNumberOfDaysInMonth: getNumberOfDaysInMonth, + getDateInNextMonth: getDateInNextMonth, + getDateInPreviousMonth: getDateInPreviousMonth, + isInNextMonth: isInNextMonth, + isInPreviousMonth: isInPreviousMonth, + getDateMidpoint: getDateMidpoint, + isSameMonthAndYear: isSameMonthAndYear, + getWeekOfMonth: getWeekOfMonth, + incrementDays: incrementDays, + incrementMonths: incrementMonths, + getLastDateOfMonth: getLastDateOfMonth, + isSameDay: isSameDay + }; + + /** + * Gets the first day of the month for the given date's month. + * @param {Date} date + * @returns {Date} + */ + function getFirstDateOfMonth(date) { + return new Date(date.getFullYear(), date.getMonth(), 1); + } + + /** + * Gets the number of days in the month for the given date's month. + * @param date + * @returns {number} + */ + function getNumberOfDaysInMonth(date) { + return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); + } + + /** + * Get an arbitrary date in the month after the given date's month. + * @param date + * @returns {Date} + */ + function getDateInNextMonth(date) { + return new Date(date.getFullYear(), date.getMonth() + 1, 1); + } + + /** + * Get an arbitrary date in the month before the given date's month. + * @param date + * @returns {Date} + */ + function getDateInPreviousMonth(date) { + return new Date(date.getFullYear(), date.getMonth() - 1, 1); + } + + /** + * Gets whether two dates have the same month and year. + * @param {Date} d1 + * @param {Date} d2 + * @returns {boolean} + */ + function isSameMonthAndYear(d1, d2) { + return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth(); + } + + /** + * Gets whether two dates are the same day (not not necesarily the same time). + * @param {Date} d1 + * @param {Date} d2 + * @returns {boolean} + */ + function isSameDay(d1, d2) { + return d1.getDate() == d2.getDate() && isSameMonthAndYear(d1, d2); + } + + /** + * Gets whether a date is in the month immediately after some date. + * @param {Date} startDate The date from which to compare. + * @param {Date} endDate The date to check. + * @returns {boolean} + */ + function isInNextMonth(startDate, endDate) { + var nextMonth = getDateInNextMonth(startDate); + return isSameMonthAndYear(nextMonth, endDate); + } + + /** + * Gets whether a date is in the month immediately before some date. + * @param {Date} startDate The date from which to compare. + * @param {Date} endDate The date to check. + * @returns {boolean} + */ + function isInPreviousMonth(startDate, endDate) { + var previousMonth = getDateInPreviousMonth(startDate); + return isSameMonthAndYear(endDate, previousMonth); + } + + /** + * Gets the midpoint between two dates. + * @param {Date} d1 + * @param {Date} d2 + * @returns {Date} + */ + function getDateMidpoint(d1, d2) { + return new Date((d1.getTime() + d2.getTime()) / 2); + } + + /** + * Gets the week of the month that a given date occurs in. + * @param {Date} date + * @returns {number} Index of the week of the month (zero-based). + */ + function getWeekOfMonth(date) { + var firstDayOfMonth = getFirstDateOfMonth(date); + return Math.floor((firstDayOfMonth.getDay() + date.getDate() - 1) / 7); + } + + /** + * Gets a new date incremented by the given number of days. Number of days can be negative. + * @param {Date} date + * @param {number} numberOfDays + * @returns {Date} + */ + function incrementDays(date, numberOfDays) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + numberOfDays); + } + + /** + * Gets a new date incremented by the given number of months. Number of months can be negative. + * If the date of the given month does not match the target month, the date will be set to the + * last day of the month. + * @param {Date} date + * @param {number} numberOfMonths + * @returns {Date} + */ + function incrementMonths(date, numberOfMonths) { + // If the same date in the target month does not actually exist, the Date object will + // automatically advance *another* month by the number of missing days. + // For example, if you try to go from Jan. 30 to Feb. 30, you'll end up on March 2. + // So, we check if the month overflowed and go to the last day of the target month instead. + var dateInTargetMonth = new Date(date.getFullYear(), date.getMonth() + numberOfMonths, 1); + var numberOfDaysInMonth = getNumberOfDaysInMonth(dateInTargetMonth); + if (numberOfDaysInMonth < date.getDate()) { + dateInTargetMonth.setDate(numberOfDaysInMonth); + } else { + dateInTargetMonth.setDate(date.getDate()); + } + + return dateInTargetMonth; + } + + /** + * Gets the last day of the month for the given date. + * @param {Date} date + * @returns {Date} + */ + function getLastDateOfMonth(date) { + return new Date(date.getFullYear(), date.getMonth(), getNumberOfDaysInMonth(date)); + } + }); +})(); diff --git a/src/components/calendar/dateUtil.spec.js b/src/components/calendar/dateUtil.spec.js new file mode 100644 index 00000000000..76d3fe7bed5 --- /dev/null +++ b/src/components/calendar/dateUtil.spec.js @@ -0,0 +1,197 @@ + +describe('$$mdDateUtil', function() { + var dateUtil; + + beforeEach(module('material.components.calendar')); + + beforeEach(inject(function($$mdDateUtil) { + dateUtil = $$mdDateUtil; + })); + + it('should get the first day of a month', function() { + var first = dateUtil.getFirstDateOfMonth(new Date(1985, 9, 26)); + + expect(first.getFullYear()).toBe(1985); + expect(first.getMonth()).toBe(9); + expect(first.getDate()).toBe(1); + }); + + it('should get the first day of the month from the first day of the month', function() { + var first = dateUtil.getFirstDateOfMonth(new Date(1985, 9, 1)); + + expect(first.getFullYear()).toBe(1985); + expect(first.getMonth()).toBe(9); + expect(first.getDate()).toBe(1); + }); + + it('should get the number of days in a month', function() { + // Month with 31 days. + expect(dateUtil.getNumberOfDaysInMonth(new Date(2015, 0, 1))).toBe(31); + + // Month with 30 days. + expect(dateUtil.getNumberOfDaysInMonth(new Date(2015, 3, 1))).toBe(30); + + // Month with 28 days + expect(dateUtil.getNumberOfDaysInMonth(new Date(2015, 1, 1))).toBe(28); + + // Month with 29 days. + expect(dateUtil.getNumberOfDaysInMonth(new Date(2012, 1, 1))).toBe(29); + }); + + it('should get an arbitrary day in the next month', function() { + // Next month in the same year. + var next = dateUtil.getDateInNextMonth(new Date(2015, 0, 1)); + expect(next.getMonth()).toBe(1); + expect(next.getFullYear()).toBe(2015); + + // Next month in the following year. + next = dateUtil.getDateInNextMonth(new Date(2015, 11, 1)); + expect(next.getMonth()).toBe(0); + expect(next.getFullYear()).toBe(2016); + }); + + it('should get an arbitrary day in the previous month', function() { + // Previous month in the same year. + var next = dateUtil.getDateInPreviousMonth(new Date(2015, 6, 1)); + expect(next.getMonth()).toBe(5); + expect(next.getFullYear()).toBe(2015); + + // Previous month in the past year. + next = dateUtil.getDateInPreviousMonth(new Date(2015, 0, 1)); + expect(next.getMonth()).toBe(11); + expect(next.getFullYear()).toBe(2014); + }); + + it('should check whether two dates are in the same month and year', function() { + // Same month and year. + var first = new Date(2015, 3, 30); + var second = new Date(2015, 3, 1); + expect(dateUtil.isSameMonthAndYear(first, second)).toBe(true); + + // Same exact day. + first = new Date(2015, 3, 1); + second = new Date(2015, 3, 1); + expect(dateUtil.isSameMonthAndYear(first, second)).toBe(true); + + // Same month, different year. + first = new Date(2015, 3, 30); + second = new Date(2005, 3, 1); + expect(dateUtil.isSameMonthAndYear(first, second)).toBe(false); + + // Same year, different month. + first = new Date(2015, 3, 30); + second = new Date(2015, 6, 1); + expect(dateUtil.isSameMonthAndYear(first, second)).toBe(false); + + // Different month and year. + first = new Date(2012, 3, 30); + second = new Date(2015, 6, 1); + expect(dateUtil.isSameMonthAndYear(first, second)).toBe(false); + }); + + it('should check whether two dates are the same day', function() { + // Same exact day and time. + var first = new Date(2015, 3, 1); + var second = new Date(2015, 3, 1); + expect(dateUtil.isSameDay(first, second)).toBe(true); + + // Same day, different time. + first = new Date(2015, 3, 30, 3); + second = new Date(2015, 3, 30, 4); + expect(dateUtil.isSameDay(first, second)).toBe(true); + + // Same month and year, different day. + first = new Date(2015, 3, 30); + second = new Date(2015, 3, 1); + expect(dateUtil.isSameDay(first, second)).toBe(false); + + // Same month, different year. + first = new Date(2015, 3, 30); + second = new Date(2005, 3, 30); + expect(dateUtil.isSameDay(first, second)).toBe(false); + + // Same year, different month. + first = new Date(2015, 3, 30); + second = new Date(2015, 6, 30); + expect(dateUtil.isSameDay(first, second)).toBe(false); + + // Different month and year. + first = new Date(2012, 3, 30); + second = new Date(2015, 6, 30); + expect(dateUtil.isSameDay(first, second)).toBe(false); + }); + + it('should check whether a date is in the next month', function() { + // Next month within the same year. + var first = new Date(2015, 6, 15); + var second = new Date(2015, 7, 25); + expect(dateUtil.isInNextMonth(first, second)).toBe(true); + + // Next month across years. + first = new Date(2015, 11, 15); + second = new Date(2016, 0, 25); + expect(dateUtil.isInNextMonth(first, second)).toBe(true); + + // Not in the next month (past, same year). + first = new Date(2015, 5, 15); + second = new Date(2015, 3, 25); + expect(dateUtil.isInNextMonth(first, second)).toBe(false); + + // Not in the next month (future, same year). + first = new Date(2015, 5, 15); + second = new Date(2015, 7, 25); + expect(dateUtil.isInNextMonth(first, second)).toBe(false); + + // Not in the next month (month + 1 in different year). + first = new Date(2015, 5, 15); + second = new Date(2016, 6, 25); + expect(dateUtil.isInNextMonth(first, second)).toBe(false); + }); + + it('should check whether a date is in the previous month', function() { + // Previous month within the same year. + var first = new Date(2015, 7, 15); + var second = new Date(2015, 6, 25); + expect(dateUtil.isInPreviousMonth(first, second)).toBe(true); + + // Previous month across years. + first = new Date(2015, 0, 15); + second = new Date(2014, 11, 25); + expect(dateUtil.isInPreviousMonth(first, second)).toBe(true); + + // Not in the previous month (past, same year). + first = new Date(2015, 5, 15); + second = new Date(2015, 3, 25); + expect(dateUtil.isInPreviousMonth(first, second)).toBe(false); + + // Not in the previous month (future, same year). + first = new Date(2015, 5, 15); + second = new Date(2015, 7, 25); + expect(dateUtil.isInPreviousMonth(first, second)).toBe(false); + + // Not in the previous month (month - 1 in different year). + first = new Date(2015, 5, 15); + second = new Date(2016, 4, 25); + expect(dateUtil.isInPreviousMonth(first, second)).toBe(false); + }); + + it('should get the midpoint between two dates', function() { + var start = new Date(2010, 2, 10); + var end = new Date(2010, 2, 20); + var midpoint = dateUtil.getDateMidpoint(start, end); + + expect(dateUtil.isSameDay(midpoint, new Date(2010, 2, 15))).toBe(true); + }); + + it('should get the week of the month in which a given date appears', function() { + }); + + it('should increment a date by a number of days', function() { + }); + + it('should increment a date by a number of months', function() { + }); + + it('should get the last date of a month', function() { + }); +}); diff --git a/src/components/calendar/demoBasicUsage/index.html b/src/components/calendar/demoBasicUsage/index.html new file mode 100644 index 00000000000..bd9ce4f9486 --- /dev/null +++ b/src/components/calendar/demoBasicUsage/index.html @@ -0,0 +1,28 @@ +
+ +

{{title}}

+ +
+

+

Development tools

+ + + +

+
+ + + +

+

+

+

Here is a bunch of stuff after the calendar

+

Here is a bunch of stuff after the calendar

+

Here is a bunch of stuff after the calendar

+

Here is a bunch of stuff after the calendar

+

Here is a bunch of stuff after the calendar

+

Here is a bunch of stuff after the calendar

+ + +
+
diff --git a/src/components/calendar/demoBasicUsage/script.js b/src/components/calendar/demoBasicUsage/script.js new file mode 100644 index 00000000000..d4e813d1cf2 --- /dev/null +++ b/src/components/calendar/demoBasicUsage/script.js @@ -0,0 +1,12 @@ +angular.module('calendarDemo1', ['ngMaterial']) + .controller('AppCtrl', function($scope) { + $scope.title = 'Calendar demo'; + $scope.myDate = new Date(); + + $scope.adjustMonth = function(delta) { + $scope.myDate = new Date( + $scope.myDate.getFullYear(), + $scope.myDate.getMonth() + delta, + $scope.myDate.getDate()); + }; + }); diff --git a/src/components/calendar/demoBasicUsage/style.css b/src/components/calendar/demoBasicUsage/style.css new file mode 100644 index 00000000000..1156334a7f7 --- /dev/null +++ b/src/components/calendar/demoBasicUsage/style.css @@ -0,0 +1 @@ +/** Demo styles for mdCalendar. */