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

Commit cecba23

Browse files
Michael ChenThomasBurleson
authored andcommitted
feat(datepicker): Add min/max dates in datepicker
Allows date objects to be passed in to the datepicker to specify minimum date and maximum date. Fixes #4158. Closes #4306.
1 parent f817193 commit cecba23

File tree

11 files changed

+331
-58
lines changed

11 files changed

+331
-58
lines changed

src/components/datepicker/calendar-theme.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616

1717
.md-calendar-date.md-calendar-date-today {
1818
color: '{{primary-500}}'; // blue-500
19+
20+
&.md-calendar-date-disabled {
21+
color: '{{primary-500-0.6}}';
22+
}
1923
}
2024

2125
// The CSS class `md-focus` is used instead of real browser focus for accessibility reasons
@@ -39,4 +43,8 @@
3943
}
4044
}
4145

46+
.md-calendar-date-disabled,
47+
.md-calendar-month-label-disabled {
48+
color: '{{background-400}}'; // grey-400
49+
}
4250
}

src/components/datepicker/calendar.js

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,19 @@
3434
*/
3535
var TBODY_HEIGHT = 265;
3636

37+
/**
38+
* Height of a calendar month with a single row. This is needed to calculate the offset for
39+
* rendering an extra month in virtual-repeat that only contains one row.
40+
*/
41+
var TBODY_SINGLE_ROW_HEIGHT = 45;
42+
3743
function calendarDirective() {
3844
return {
3945
template:
4046
'<table aria-hidden="true" class="md-calendar-day-header"><thead></thead></table>' +
4147
'<div class="md-calendar-scroll-mask">' +
42-
'<md-virtual-repeat-container class="md-calendar-scroll-container">' +
48+
'<md-virtual-repeat-container class="md-calendar-scroll-container" ' +
49+
'md-offset-size="' + (TBODY_SINGLE_ROW_HEIGHT - TBODY_HEIGHT) + '">' +
4350
'<table role="grid" tabindex="0" class="md-calendar" aria-readonly="true">' +
4451
'<tbody role="rowgroup" md-virtual-repeat="i in ctrl.items" md-calendar-month ' +
4552
'md-month-offset="$index" class="md-calendar-month" ' +
@@ -48,7 +55,10 @@
4855
'</table>' +
4956
'</md-virtual-repeat-container>' +
5057
'</div>',
51-
scope: {},
58+
scope: {
59+
minDate: '=mdMinDate',
60+
maxDate: '=mdMaxDate',
61+
},
5262
require: ['ngModel', 'mdCalendar'],
5363
controller: CalendarCtrl,
5464
controllerAs: 'ctrl',
@@ -87,6 +97,15 @@
8797
*/
8898
this.items = {length: 2000};
8999

100+
if (this.maxDate && this.minDate) {
101+
// Limit the number of months if min and max dates are set.
102+
var numMonths = $$mdDateUtil.getMonthDistance(this.minDate, this.maxDate) + 1;
103+
numMonths = Math.max(numMonths, 1);
104+
// Add an additional month as the final dummy month for rendering purposes.
105+
numMonths += 1;
106+
this.items.length = numMonths;
107+
}
108+
90109
/** @final {!angular.$animate} */
91110
this.$animate = $animate;
92111

@@ -123,9 +142,19 @@
123142
/** @final {Date} */
124143
this.today = this.dateUtil.createDateAtMidnight();
125144

126-
// Set the first renderable date once for all calendar instances.
127-
firstRenderableDate =
128-
firstRenderableDate || this.dateUtil.incrementMonths(this.today, -this.items.length / 2);
145+
/** @type {Date} */
146+
this.firstRenderableDate = this.dateUtil.incrementMonths(this.today, -this.items.length / 2);
147+
148+
if (this.minDate && this.minDate > this.firstRenderableDate) {
149+
this.firstRenderableDate = this.minDate;
150+
} else if (this.maxDate) {
151+
// Calculate the difference between the start date and max date.
152+
// Subtract 1 because it's an inclusive difference and 1 for the final dummy month.
153+
//
154+
var monthDifference = this.items.length - 2;
155+
this.firstRenderableDate = this.dateUtil.incrementMonths(this.maxDate, -(this.items.length - 2));
156+
}
157+
129158

130159
/** @final {number} Unique ID for this calendar instance. */
131160
this.id = nextUniqueId++;
@@ -279,6 +308,7 @@
279308
// Selection isn't occuring, so the key event is either navigation or nothing.
280309
var date = self.getFocusDateFromKeyEvent(event);
281310
if (date) {
311+
date = self.boundDateByMinAndMax(date);
282312
event.preventDefault();
283313
event.stopPropagation();
284314

@@ -324,7 +354,8 @@
324354
* @returns {number}
325355
*/
326356
CalendarCtrl.prototype.getSelectedMonthIndex = function() {
327-
return this.dateUtil.getMonthDistance(firstRenderableDate, this.selectedDate || this.today);
357+
return this.dateUtil.getMonthDistance(this.firstRenderableDate,
358+
this.selectedDate || this.today);
328359
};
329360

330361
/**
@@ -336,7 +367,7 @@
336367
return;
337368
}
338369

339-
var monthDistance = this.dateUtil.getMonthDistance(firstRenderableDate, date);
370+
var monthDistance = this.dateUtil.getMonthDistance(this.firstRenderableDate, date);
340371
this.calendarScroller.scrollTop = monthDistance * TBODY_HEIGHT;
341372
};
342373

@@ -372,6 +403,23 @@
372403
}
373404
};
374405

406+
/**
407+
* If a date exceeds minDate or maxDate, returns date matching minDate or maxDate, respectively.
408+
* Otherwise, returns the date.
409+
* @param {Date} date
410+
* @return {Date}
411+
*/
412+
CalendarCtrl.prototype.boundDateByMinAndMax = function(date) {
413+
var boundDate = date;
414+
if (this.minDate && date < this.minDate) {
415+
boundDate = new Date(this.minDate.getTime());
416+
}
417+
if (this.maxDate && date > this.maxDate) {
418+
boundDate = new Date(this.maxDate.getTime());
419+
}
420+
return boundDate;
421+
};
422+
375423
/*** Updating the displayed / selected date ***/
376424

377425
/**

src/components/datepicker/calendar.scss

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ $md-calendar-width: (7 * $md-calendar-cell-size) + (2 * $md-calendar-side-paddin
1212
$md-calendar-height:
1313
($md-calendar-weeks-to-show * $md-calendar-cell-size) + $md-calendar-header-height;
1414

15-
1615
// Styles for date cells, including day-of-the-week header cells.
1716
@mixin md-calendar-cell() {
1817
height: $md-calendar-cell-size;
@@ -88,6 +87,10 @@ md-calendar {
8887
// A single date cell in the calendar table.
8988
.md-calendar-date {
9089
@include md-calendar-cell();
90+
91+
&.md-calendar-date-disabled {
92+
cursor: default;
93+
}
9194
}
9295

9396
// Circle element inside of every date cell used to indicate selection or focus.
@@ -97,11 +100,13 @@ md-calendar {
97100
border-radius: 50%;
98101
display: inline-block;
99102

100-
cursor: pointer;
101-
102103
width: $md-calendar-cell-emphasis-size;
103104
height: $md-calendar-cell-emphasis-size;
104105
line-height: $md-calendar-cell-emphasis-size;
106+
107+
.md-calendar-date:not(.md-disabled) & {
108+
cursor: pointer;
109+
}
105110
}
106111

107112
// The label above each month (containing the month name and the year, e.g. "Jun 2014").

src/components/datepicker/calendar.spec.js

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,23 @@ describe('md-calendar', function() {
5050
}
5151
}
5252

53+
/**
54+
* Finds a month `tbody` in the calendar element given a date.
55+
*/
56+
function findMonthElement(date) {
57+
var months = element.querySelectorAll('[md-calendar-month]');
58+
var monthHeader = dateLocale.monthHeaderFormatter(date);
59+
var month;
60+
61+
for (var i = 0; i < months.length; i++) {
62+
month = months[i];
63+
if (month.querySelector('tr:first-child td:first-child').textContent === monthHeader) {
64+
return month;
65+
}
66+
}
67+
return null;
68+
}
69+
5370
/**
5471
* Gets the month label for a given date cell.
5572
* @param {HTMLElement|DocumentView} cell
@@ -63,7 +80,8 @@ describe('md-calendar', function() {
6380
/** Creates and compiles an md-calendar element. */
6481
function createElement(parentScope) {
6582
var directiveScope = parentScope || $rootScope.$new();
66-
var template = '<md-calendar ng-model="myDate"></md-calendar>';
83+
var template = '<md-calendar md-min-date="minDate" md-max-date="maxDate" ' +
84+
'ng-model="myDate"></md-calendar>';
6785
var attachedElement = angular.element(template);
6886
document.body.appendChild(attachedElement[0]);
6987
var newElement = $compile(attachedElement)(directiveScope);
@@ -135,7 +153,7 @@ describe('md-calendar', function() {
135153

136154
ngElement = createElement(pageScope);
137155
element = ngElement[0];
138-
scope = ngElement.scope();
156+
scope = ngElement.isolateScope();
139157
controller = ngElement.controller('mdCalendar');
140158
}));
141159

@@ -227,6 +245,40 @@ describe('md-calendar', function() {
227245
var monthHeader = monthElement.querySelector('tr');
228246
expect(monthHeader.textContent).toEqual('Junz 2014');
229247
});
248+
249+
it('should update the model on cell click', function() {
250+
spyOn(scope, '$emit');
251+
var date = new Date(2014, MAY, 30);
252+
var monthElement = monthCtrl.buildCalendarForMonth(date);
253+
var expectedDate = new Date(2014, MAY, 5);
254+
findDateElement(monthElement, 5).click();
255+
expect(pageScope.myDate).toBeSameDayAs(expectedDate);
256+
expect(scope.$emit).toHaveBeenCalledWith('md-calendar-change', expectedDate);
257+
});
258+
259+
it('should disable any dates outside the min/max date range', function() {
260+
pageScope.minDate = new Date(2014, JUN, 10);
261+
pageScope.maxDate = new Date(2014, JUN, 20);
262+
pageScope.$apply();
263+
264+
var monthElement = monthCtrl.buildCalendarForMonth(new Date(2014, JUN, 15));
265+
expect(findDateElement(monthElement, 5)).toHaveClass('md-calendar-date-disabled');
266+
expect(findDateElement(monthElement, 10)).not.toHaveClass('md-calendar-date-disabled');
267+
expect(findDateElement(monthElement, 20)).not.toHaveClass('md-calendar-date-disabled');
268+
expect(findDateElement(monthElement, 25)).toHaveClass('md-calendar-date-disabled');
269+
});
270+
271+
it('should not respond to disabled cell clicks', function() {
272+
var initialDate = new Date(2014, JUN, 15);
273+
pageScope.myDate = initialDate;
274+
pageScope.minDate = new Date(2014, JUN, 10);
275+
pageScope.maxDate = new Date(2014, JUN, 20);
276+
pageScope.$apply();
277+
278+
var monthElement = monthCtrl.buildCalendarForMonth(pageScope.myDate);
279+
findDateElement(monthElement, 5).click();
280+
expect(pageScope.myDate).toBeSameDayAs(initialDate);
281+
});
230282
});
231283

232284
it('should highlight today', function() {
@@ -325,6 +377,41 @@ describe('md-calendar', function() {
325377
expect(controller.selectedDate).toBeSameDayAs(new Date(2014, MAR, 1));
326378
});
327379

380+
it('should restrict date navigation to min/max dates', function() {
381+
pageScope.minDate = new Date(2014, FEB, 5);
382+
pageScope.maxDate = new Date(2014, FEB, 10);
383+
pageScope.myDate = new Date(2014, FEB, 8);
384+
applyDateChange();
385+
386+
var selectedDate = element.querySelector('.md-calendar-selected-date');
387+
selectedDate.focus();
388+
389+
dispatchKeyEvent(keyCodes.UP_ARROW);
390+
expect(getFocusedDateElement().textContent).toBe('5');
391+
expect(getMonthLabelForDateCell(getFocusedDateElement())).toBe('Feb 2014');
392+
393+
dispatchKeyEvent(keyCodes.LEFT_ARROW);
394+
expect(getFocusedDateElement().textContent).toBe('5');
395+
expect(getMonthLabelForDateCell(getFocusedDateElement())).toBe('Feb 2014');
396+
397+
dispatchKeyEvent(keyCodes.DOWN_ARROW);
398+
expect(getFocusedDateElement().textContent).toBe('10');
399+
expect(getMonthLabelForDateCell(getFocusedDateElement())).toBe('Feb 2014');
400+
401+
dispatchKeyEvent(keyCodes.RIGHT_ARROW);
402+
expect(getFocusedDateElement().textContent).toBe('10');
403+
expect(getMonthLabelForDateCell(getFocusedDateElement())).toBe('Feb 2014');
404+
405+
dispatchKeyEvent(keyCodes.UP_ARROW, {meta: true});
406+
expect(getFocusedDateElement().textContent).toBe('5');
407+
expect(getMonthLabelForDateCell(getFocusedDateElement())).toBe('Feb 2014');
408+
409+
dispatchKeyEvent(keyCodes.DOWN_ARROW, {meta: true});
410+
expect(getFocusedDateElement().textContent).toBe('10');
411+
expect(getMonthLabelForDateCell(getFocusedDateElement())).toBe('Feb 2014');
412+
413+
});
414+
328415
it('should fire an event when escape is pressed', function() {
329416
var escapeHandler = jasmine.createSpy('escapeHandler');
330417
pageScope.$on('md-calendar-close', escapeHandler);
@@ -354,4 +441,44 @@ describe('md-calendar', function() {
354441
controller.changeDisplayDate(laterDate);
355442
expect(controller.displayDate).toBeSameDayAs(laterDate);
356443
});
444+
445+
it('should not render any months before the min date', function() {
446+
ngElement.remove();
447+
var newScope = $rootScope.$new();
448+
newScope.minDate = new Date(2014, JUN, 5);
449+
newScope.myDate = new Date(2014, JUN, 15);
450+
newScope.$apply();
451+
element = createElement(newScope)[0];
452+
453+
expect(findMonthElement(new Date(2014, JUL, 1))).not.toBeNull();
454+
expect(findMonthElement(new Date(2014, JUN, 1))).not.toBeNull();
455+
expect(findMonthElement(new Date(2014, MAY, 1))).toBeNull();
456+
});
457+
458+
it('should render one single-row month of disabled cells after the max date', function() {
459+
ngElement.remove();
460+
var newScope = $rootScope.$new();
461+
newScope.myDate = new Date(2014, APR, 15);
462+
newScope.maxDate = new Date(2014, APR, 30);
463+
newScope.$apply();
464+
element = createElement(newScope)[0];
465+
466+
expect(findMonthElement(new Date(2014, MAR, 1))).not.toBeNull();
467+
expect(findMonthElement(new Date(2014, APR, 1))).not.toBeNull();
468+
469+
// First date of May 2014 on Thursday (i.e. has 3 dates on the first row).
470+
var nextMonth = findMonthElement(new Date(2014, MAY, 1));
471+
expect(nextMonth).not.toBeNull();
472+
expect(nextMonth.querySelector('.md-calendar-month-label')).toHaveClass(
473+
'md-calendar-month-label-disabled');
474+
expect(nextMonth.querySelectorAll('tr').length).toBe(1);
475+
476+
var dates = nextMonth.querySelectorAll('.md-calendar-date');
477+
for (var i = 0; i < dates.length; i++) {
478+
date = dates[i];
479+
if (date.textContent) {
480+
expect(date).toHaveClass('md-calendar-date-disabled');
481+
}
482+
}
483+
});
357484
});

0 commit comments

Comments
 (0)