Skip to content
This repository was archived by the owner on Sep 5, 2024. It is now read-only.

Commit eecc976

Browse files
crisbetoandrewseguin
authored andcommitted
feat(datepicker): add the ability to restrict users to a calendar view (#9736)
* Adds the ability to restrict users to a calendar view. For example, it's now possible to let users only select a month, instead of having to pick out a date within the month. This can be useful for cases like a credit card form, where the day of the month doesn't necessarily make sense. * Fixes an issue where the year view wouldn't highlight the proper cell, if the model was changed externally and the date was different from the first day of the month. Fixes #9260.
1 parent 5165489 commit eecc976

File tree

8 files changed

+232
-75
lines changed

8 files changed

+232
-75
lines changed

src/components/datepicker/calendar.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ md-calendar {
127127
font-weight: 500; // Roboto Medium
128128
@include rtl(padding, 0 0 0 $md-calendar-side-padding + $md-calendar-month-label-padding, rtl-value( 0 0 0 $md-calendar-side-padding + $md-calendar-month-label-padding));
129129

130-
md-calendar-month &:not(.md-calendar-month-label-disabled) {
130+
&.md-calendar-label-clickable {
131131
cursor: pointer;
132132
}
133133

src/components/datepicker/demoBasicUsage/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,10 @@ <h4>Custom calendar trigger</h4>
2929
<md-datepicker ng-model="ctrl.myDate" md-placeholder="Enter date" md-is-open="ctrl.isOpen"></md-datepicker>
3030
<md-button class="md-primary md-raised" ng-click="ctrl.isOpen = true">Open</md-button>
3131
</div>
32+
33+
<div flex-gt-xs>
34+
<h4>Date-picker that only allows for the month to be selected</h4>
35+
<md-datepicker ng-model="ctrl.myDate" md-placeholder="Enter date" md-mode="month"></md-datepicker>
36+
</div>
3237
</div>
3338
</md-content>

src/components/datepicker/js/calendar.js

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
* @param {Date=} md-min-date Expression representing the minimum date.
1111
* @param {Date=} md-max-date Expression representing the maximum date.
1212
* @param {(function(Date): boolean)=} md-date-filter Function expecting a date and returning a boolean whether it can be selected or not.
13+
* @param {String=} md-current-view Current view of the calendar. Can be either "month" or "year".
14+
* @param {String=} md-mode Restricts the user to only selecting a value from a particular view. This option can
15+
* be used if the user is only supposed to choose from a certain date type (e.g. only selecting the month).
16+
* Can be either "month" or "day". **Note** that this will ovewrite the `md-current-view` value.
1317
*
1418
* @description
1519
* `<md-calendar>` is a component that renders a calendar that can be used to select a date.
@@ -58,6 +62,10 @@
5862
minDate: '=mdMinDate',
5963
maxDate: '=mdMaxDate',
6064
dateFilter: '=mdDateFilter',
65+
66+
// These need to be prefixed, because Angular resets
67+
// any changes to the value due to bindToController.
68+
_mode: '@mdMode',
6169
_currentView: '@mdCurrentView'
6270
},
6371
require: ['ngModel', 'mdCalendar'],
@@ -84,6 +92,12 @@
8492
/** Next identifier for calendar instance. */
8593
var nextUniqueId = 0;
8694

95+
/** Maps the `md-mode` values to their corresponding calendar views. */
96+
var MODE_MAP = {
97+
day: 'month',
98+
month: 'year'
99+
};
100+
87101
/**
88102
* Controller for the mdCalendar component.
89103
* @ngInject @constructor
@@ -192,8 +206,6 @@
192206

193207
var boundKeyHandler = angular.bind(this, this.handleKeyEvent);
194208

195-
196-
197209
// If use the md-calendar directly in the body without datepicker,
198210
// handleKeyEvent will disable other inputs on the page.
199211
// So only apply the handleKeyEvent on the body when the md-calendar inside datepicker,
@@ -227,14 +239,19 @@
227239
* Bindings are not guaranteed to have been assigned in the controller, but they are in the $onInit hook.
228240
*/
229241
CalendarCtrl.prototype.$onInit = function() {
230-
231242
/**
232243
* The currently visible calendar view. Note the prefix on the scope value,
233244
* which is necessary, because the datepicker seems to reset the real one value if the
234-
* calendar is open, but the value on the datepicker's scope is empty.
245+
* calendar is open, but the `currentView` on the datepicker's scope is empty.
235246
* @type {String}
236247
*/
237-
this.currentView = this._currentView || 'month';
248+
if (this._mode && MODE_MAP.hasOwnProperty(this._mode)) {
249+
this.currentView = MODE_MAP[this._mode];
250+
this.mode = this._mode;
251+
} else {
252+
this.currentView = this._currentView || 'month';
253+
this.mode = null;
254+
}
238255

239256
var dateLocale = this.$mdDateLocale;
240257

@@ -318,7 +335,7 @@
318335
*/
319336
CalendarCtrl.prototype.focus = function(date) {
320337
if (this.dateUtil.isValidDate(date)) {
321-
var previousFocus = this.$element[0].querySelector('.md-focus');
338+
var previousFocus = this.$element[0].querySelector('.' + this.FOCUSED_DATE_CLASS);
322339
if (previousFocus) {
323340
previousFocus.classList.remove(this.FOCUSED_DATE_CLASS);
324341
}
@@ -339,6 +356,32 @@
339356
}
340357
};
341358

359+
/**
360+
* Highlights a date cell on the calendar and changes the selected date.
361+
* @param {Date=} date Date to be marked as selected.
362+
*/
363+
CalendarCtrl.prototype.changeSelectedDate = function(date) {
364+
var selectedDateClass = this.SELECTED_DATE_CLASS;
365+
var prevDateCell = this.$element[0].querySelector('.' + selectedDateClass);
366+
367+
// Remove the selected class from the previously selected date, if any.
368+
if (prevDateCell) {
369+
prevDateCell.classList.remove(selectedDateClass);
370+
prevDateCell.setAttribute('aria-selected', 'false');
371+
}
372+
373+
// Apply the select class to the new selected date if it is set.
374+
if (date) {
375+
var dateCell = document.getElementById(this.getDateId(date, this.currentView));
376+
if (dateCell) {
377+
dateCell.classList.add(selectedDateClass);
378+
dateCell.setAttribute('aria-selected', 'true');
379+
}
380+
}
381+
382+
this.selectedDate = date;
383+
};
384+
342385
/**
343386
* Normalizes the key event into an action name. The action will be broadcast
344387
* to the child controllers.
@@ -355,8 +398,6 @@
355398
case keyCode.RIGHT_ARROW: return 'move-right';
356399
case keyCode.LEFT_ARROW: return 'move-left';
357400

358-
// TODO(crisbeto): Might want to reconsider using metaKey, because it maps
359-
// to the "Windows" key on PC, which opens the start menu or resizes the browser.
360401
case keyCode.DOWN_ARROW: return event.metaKey ? 'move-page-down' : 'move-row-down';
361402
case keyCode.UP_ARROW: return event.metaKey ? 'move-page-up' : 'move-row-up';
362403

src/components/datepicker/js/calendar.spec.js

Lines changed: 130 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ describe('md-calendar', function() {
1717
*/
1818
function applyDateChange() {
1919
$timeout.flush();
20-
pageScope.$apply();
2120
$material.flushOutstandingAnimations();
2221

2322
// Internally, the calendar sets scrollTop to scroll to the month for a change.
@@ -57,7 +56,7 @@ describe('md-calendar', function() {
5756
/**
5857
* Finds a month `tbody` in the calendar element given a date.
5958
*/
60-
function findMonthElement(date) {
59+
function findMonthElement(element, date) {
6160
var months = element.querySelectorAll('[md-calendar-month-body]');
6261
var monthHeader = dateLocale.monthHeaderFormatter(date);
6362
var month;
@@ -72,8 +71,8 @@ describe('md-calendar', function() {
7271
}
7372

7473
/** Find the `tbody` for a year in the calendar. */
75-
function findYearElement(year) {
76-
var node = element[0] || element;
74+
function findYearElement(parent, year) {
75+
var node = parent[0] || parent;
7776
var years = node.querySelectorAll('[md-calendar-year-body]');
7877
var yearHeader = year.toString();
7978
var target;
@@ -415,9 +414,9 @@ describe('md-calendar', function() {
415414
newScope.$apply();
416415
element = createElement(newScope)[0];
417416

418-
expect(findMonthElement(new Date(2014, JUL, 1))).not.toBeNull();
419-
expect(findMonthElement(new Date(2014, JUN, 1))).not.toBeNull();
420-
expect(findMonthElement(new Date(2014, MAY, 1))).toBeNull();
417+
expect(findMonthElement(element, new Date(2014, JUL, 1))).not.toBeNull();
418+
expect(findMonthElement(element, new Date(2014, JUN, 1))).not.toBeNull();
419+
expect(findMonthElement(element, new Date(2014, MAY, 1))).toBeNull();
421420
});
422421
});
423422

@@ -514,8 +513,27 @@ describe('md-calendar', function() {
514513
element.controller('mdCalendar').setCurrentView('year');
515514
applyDateChange();
516515

517-
expect(findYearElement(2014)).not.toBeNull();
518-
expect(findYearElement(2013)).toBeNull();
516+
expect(findYearElement(element, 2014)).not.toBeNull();
517+
expect(findYearElement(element, 2013)).toBeNull();
518+
});
519+
520+
it('should highlight the proper cell, even when the date is not the ' +
521+
'first day of the month', function() {
522+
var newScope = $rootScope.$new();
523+
524+
newScope.myDate = new Date(2015, MAY, 1);
525+
element = createElement(newScope)[0];
526+
angular.element(element).controller('mdCalendar').setCurrentView('year');
527+
applyDateChange();
528+
529+
var yearElement = findYearElement(element, 2015);
530+
531+
expect(findCellByLabel(yearElement, 'May')).toHaveClass('md-calendar-selected-date');
532+
533+
newScope.myDate = new Date(2015, JUL, 15);
534+
applyDateChange();
535+
536+
expect(findCellByLabel(yearElement, 'Jul')).toHaveClass('md-calendar-selected-date');
519537
});
520538

521539
it('should ensure that all year elements have a height when the ' +
@@ -737,6 +755,106 @@ describe('md-calendar', function() {
737755
});
738756
});
739757

758+
describe('md-mode support', function() {
759+
var element, controller;
760+
761+
function compileElement(attrs) {
762+
ngElement.remove();
763+
element = createElement(pageScope, '<md-calendar ng-model="myDate" ' + (attrs || '') + '></md-calendar>');
764+
controller = element.controller('mdCalendar');
765+
}
766+
767+
it('should go to the corresponding view', function() {
768+
compileElement('md-mode="month"');
769+
770+
expect(element.find('md-calendar-month').length).toBe(0);
771+
expect(element.find('md-calendar-year').length).toBe(1);
772+
expect(controller.currentView).toBe('year');
773+
});
774+
775+
it('should override md-current-view', function() {
776+
compileElement('md-mode="day" md-current-view="year"');
777+
778+
expect(element.find('md-calendar-year').length).toBe(0);
779+
expect(element.find('md-calendar-month').length).toBe(1);
780+
expect(controller.currentView).toBe('month');
781+
});
782+
783+
it('should not allow users to go to a different view', function() {
784+
compileElement('md-mode="day"');
785+
786+
expect(controller.currentView).toBe('month');
787+
788+
element[0].querySelector('.md-calendar-month-label').click();
789+
applyDateChange();
790+
791+
expect(controller.currentView).toBe('month');
792+
});
793+
794+
it('should allow users to navigate to a different view if the md-mode is not supported', function() {
795+
compileElement('md-mode="invalid-mode"');
796+
797+
expect(controller.currentView).toBe('month');
798+
expect(controller.mode).toBeFalsy();
799+
800+
element[0].querySelector('.md-calendar-month-label').click();
801+
applyDateChange();
802+
803+
expect(controller.currentView).toBe('year');
804+
});
805+
806+
it('should update the model when clicking on a cell in the year view', function() {
807+
pageScope.myDate = new Date(2015, MAY, 15);
808+
809+
compileElement('md-mode="month"');
810+
811+
expect(controller.currentView).toBe('year');
812+
813+
var yearElement = findYearElement(element[0], 2015);
814+
var monthCell = findCellByLabel(yearElement, 'Sep');
815+
var expectedDate = new Date(2015, SEP, 1);
816+
817+
monthCell.click();
818+
applyDateChange();
819+
820+
expect(pageScope.myDate).toBeSameDayAs(expectedDate);
821+
expect(controller.currentView).toBe('year');
822+
});
823+
824+
it('should update the model when clicking on a cell in the day view', function() {
825+
pageScope.myDate = new Date(2015, MAY, 15);
826+
827+
compileElement('md-mode="day"');
828+
829+
var monthElement = findMonthElement(element[0], pageScope.myDate);
830+
var monthCell = findCellByLabel(monthElement, '28');
831+
var expectedDate = new Date(2015, MAY, 28);
832+
833+
monthCell.click();
834+
applyDateChange();
835+
836+
expect(pageScope.myDate).toBeSameDayAs(expectedDate);
837+
});
838+
});
839+
840+
describe('md-current-view support', function() {
841+
beforeEach(function() {
842+
ngElement && ngElement.remove();
843+
});
844+
845+
it('should have a configurable default view', function() {
846+
var calendar = createElement(null, '<md-calendar ng-model="myDate" md-current-view="year"></md-calendar>')[0];
847+
848+
expect(calendar.querySelector('md-calendar-month')).toBeFalsy();
849+
expect(calendar.querySelector('md-calendar-year')).toBeTruthy();
850+
});
851+
852+
it('should default to the month view if no view is supploied', function() {
853+
var calendar = createElement(null, '<md-calendar ng-model="myDate"></md-calendar>');
854+
expect(calendar.controller('mdCalendar').currentView).toBe('month');
855+
});
856+
});
857+
740858
it('should render one single-row month of disabled cells after the max date', function() {
741859
ngElement.remove();
742860
var newScope = $rootScope.$new();
@@ -745,11 +863,11 @@ describe('md-calendar', function() {
745863
newScope.$apply();
746864
element = createElement(newScope)[0];
747865

748-
expect(findMonthElement(new Date(2014, MAR, 1))).not.toBeNull();
749-
expect(findMonthElement(new Date(2014, APR, 1))).not.toBeNull();
866+
expect(findMonthElement(element, new Date(2014, MAR, 1))).not.toBeNull();
867+
expect(findMonthElement(element, new Date(2014, APR, 1))).not.toBeNull();
750868

751869
// First date of May 2014 on Thursday (i.e. has 3 dates on the first row).
752-
var nextMonth = findMonthElement(new Date(2014, MAY, 1));
870+
var nextMonth = findMonthElement(element, new Date(2014, MAY, 1));
753871
expect(nextMonth).not.toBeNull();
754872
expect(nextMonth.querySelector('.md-calendar-month-label')).toHaveClass(
755873
'md-calendar-month-label-disabled');
@@ -764,12 +882,4 @@ describe('md-calendar', function() {
764882
}
765883
}
766884
});
767-
768-
it('should have a configurable default view', function() {
769-
ngElement.remove();
770-
var calendar = createElement(null, '<md-calendar ng-model="myDate" md-current-view="year"></md-calendar>')[0];
771-
772-
expect(calendar.querySelector('md-calendar-month')).toBeFalsy();
773-
expect(calendar.querySelector('md-calendar-year')).toBeTruthy();
774-
});
775885
});

src/components/datepicker/js/calendarMonth.js

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -156,40 +156,6 @@
156156
);
157157
};
158158

159-
/**
160-
* Change the selected date in the calendar (ngModel value has already been changed).
161-
* @param {Date} date
162-
*/
163-
CalendarMonthCtrl.prototype.changeSelectedDate = function(date) {
164-
var self = this;
165-
var calendarCtrl = self.calendarCtrl;
166-
var previousSelectedDate = calendarCtrl.selectedDate;
167-
calendarCtrl.selectedDate = date;
168-
169-
this.changeDisplayDate(date).then(function() {
170-
var selectedDateClass = calendarCtrl.SELECTED_DATE_CLASS;
171-
var namespace = 'month';
172-
173-
// Remove the selected class from the previously selected date, if any.
174-
if (previousSelectedDate) {
175-
var prevDateCell = document.getElementById(calendarCtrl.getDateId(previousSelectedDate, namespace));
176-
if (prevDateCell) {
177-
prevDateCell.classList.remove(selectedDateClass);
178-
prevDateCell.setAttribute('aria-selected', 'false');
179-
}
180-
}
181-
182-
// Apply the select class to the new selected date if it is set.
183-
if (date) {
184-
var dateCell = document.getElementById(calendarCtrl.getDateId(date, namespace));
185-
if (dateCell) {
186-
dateCell.classList.add(selectedDateClass);
187-
dateCell.setAttribute('aria-selected', 'true');
188-
}
189-
}
190-
});
191-
};
192-
193159
/**
194160
* Change the date that is being shown in the calendar. If the given date is in a different
195161
* month, the displayed month will be transitioned.
@@ -262,7 +228,8 @@
262228
var self = this;
263229

264230
self.$scope.$on('md-calendar-parent-changed', function(event, value) {
265-
self.changeSelectedDate(value);
231+
self.calendarCtrl.changeSelectedDate(value);
232+
self.changeDisplayDate(value);
266233
});
267234

268235
self.$scope.$on('md-calendar-parent-action', angular.bind(this, this.handleKeyEvent));

0 commit comments

Comments
 (0)