Skip to content

Commit cb5639b

Browse files
committed
feat(datepicker): implement comparison and overlap ranges
Adds the ability to render a comparison range in the date range picker. When the comparison overlaps with the primary range, the overlapping dates are shown in a separate "overlap" range.
1 parent d822649 commit cb5639b

17 files changed

+232
-45
lines changed

src/dev-app/datepicker/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ ng_module(
2727
sass_binary(
2828
name = "datepicker_demo_scss",
2929
src = "datepicker-demo.scss",
30+
deps = [
31+
"//src/material/datepicker:datepicker_scss_lib",
32+
],
3033
)
3134

3235
sass_binary(

src/dev-app/datepicker/datepicker-demo.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ <h2>Range picker</h2>
181181
[min]="minDate"
182182
[max]="maxDate"
183183
[disabled]="inputDisabled"
184+
[comparisonStart]="comparisonStart"
185+
[comparisonEnd]="comparisonEnd"
184186
[dateFilter]="filterOdd ? dateFilter : undefined">
185187
<input matStartDate formControlName="start" placeholder="Start date"/>
186188
<input matEndDate formControlName="end" placeholder="End date"/>
@@ -203,6 +205,8 @@ <h2>Range picker</h2>
203205
[min]="minDate"
204206
[max]="maxDate"
205207
[disabled]="inputDisabled"
208+
[comparisonStart]="comparisonStart"
209+
[comparisonEnd]="comparisonEnd"
206210
[dateFilter]="filterOdd ? dateFilter : undefined">
207211
<input matStartDate formControlName="start" placeholder="Start date"/>
208212
<input matEndDate formControlName="end" placeholder="End date"/>
@@ -211,6 +215,7 @@ <h2>Range picker</h2>
211215
<mat-date-range-picker
212216
[touchUi]="touch"
213217
[disabled]="datepickerDisabled"
218+
panelClass="demo-custom-range"
214219
#range2Picker></mat-date-range-picker>
215220
</mat-form-field>
216221
<div>{{range2.value | json}}</div>
@@ -225,6 +230,8 @@ <h2>Range picker</h2>
225230
[min]="minDate"
226231
[max]="maxDate"
227232
[disabled]="inputDisabled"
233+
[comparisonStart]="comparisonStart"
234+
[comparisonEnd]="comparisonEnd"
228235
[dateFilter]="filterOdd ? dateFilter : undefined">
229236
<input matStartDate formControlName="start" placeholder="Start date"/>
230237
<input matEndDate formControlName="end" placeholder="End date"/>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
@import '../../material/datepicker/datepicker-theme';
2+
13
mat-calendar {
24
width: 300px;
35
}
46

57
.demo-range-group {
68
margin-bottom: 30px;
79
}
10+
11+
.demo-custom-range {
12+
@include mat-datepicker-range-colors(hotpink, teal, yellow, purple);
13+
}

src/dev-app/datepicker/datepicker-demo.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
Inject,
1414
OnDestroy,
1515
Optional,
16-
ViewChild
16+
ViewChild,
17+
ViewEncapsulation
1718
} from '@angular/core';
1819
import {FormControl, FormGroup} from '@angular/forms';
1920
import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats, ThemePalette} from '@angular/material/core';
@@ -30,6 +31,7 @@ import {takeUntil} from 'rxjs/operators';
3031
selector: 'datepicker-demo',
3132
templateUrl: 'datepicker-demo.html',
3233
styleUrls: ['datepicker-demo.css'],
34+
encapsulation: ViewEncapsulation.None,
3335
changeDetection: ChangeDetectionStrategy.OnPush,
3436
})
3537
export class DatepickerDemo {
@@ -50,6 +52,16 @@ export class DatepickerDemo {
5052
range1 = new FormGroup({start: new FormControl(), end: new FormControl()});
5153
range2 = new FormGroup({start: new FormControl(), end: new FormControl()});
5254
range3 = new FormGroup({start: new FormControl(), end: new FormControl()});
55+
comparisonStart: Date;
56+
comparisonEnd: Date;
57+
58+
constructor() {
59+
const today = new Date();
60+
const year = today.getFullYear();
61+
const month = today.getMonth();
62+
this.comparisonStart = new Date(year, month, 9);
63+
this.comparisonEnd = new Date(year, month, 13);
64+
}
5365

5466
dateFilter: (date: Date | null) => boolean =
5567
(date: Date | null) => {

src/material/datepicker/_datepicker-theme.scss

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@ $mat-calendar-body-font-size: 13px !default;
1212
$mat-calendar-weekday-table-font-size: 11px !default;
1313

1414
@mixin _mat-datepicker-color($palette) {
15+
@include mat-datepicker-range-colors(
16+
mat-color($palette, default, $mat-datepicker-range-fade-amount));
17+
1518
.mat-calendar-body-selected {
1619
background-color: mat-color($palette);
1720
color: mat-color($palette, default-contrast);
1821
}
1922

20-
.mat-calendar-body-in-range::before {
21-
background-color: mat-color($palette, default, $mat-datepicker-range-fade-amount);
22-
}
23-
2423
.mat-calendar-body-disabled > .mat-calendar-body-selected {
2524
$background: mat-color($palette);
2625

@@ -167,3 +166,44 @@ $mat-calendar-weekday-table-font-size: 11px !default;
167166
}
168167
}
169168
}
169+
170+
@mixin mat-datepicker-range-colors(
171+
$range-color,
172+
$comparison-color: rgba(#f9ab00, $mat-datepicker-range-fade-amount),
173+
$overlap-color: #a8dab5,
174+
$overlap-selected-color: darken($overlap-color, 30%)) {
175+
176+
// stylelint-disable max-line-length
177+
.mat-calendar-body-in-range::before,
178+
.mat-calendar-body-in-comparison-range.mat-calendar-body-in-range::before,
179+
180+
// Handles the case where a cell is the end of the comparison range and the start of the main
181+
// range. Here we should use the main range color, rather than the overlap so that it transitions
182+
// seamlessly into the main range.
183+
.mat-calendar-body-comparison-end.mat-calendar-body-range-start:not(.mat-calendar-body-range-end)::after {
184+
background: $range-color;
185+
}
186+
// stylelint-enable
187+
188+
.mat-calendar-body-in-comparison-range::before,
189+
.mat-calendar-body-in-comparison-range.mat-calendar-body-range-start::before,
190+
.mat-calendar-body-in-comparison-range.mat-calendar-body-range-end::before {
191+
background: $comparison-color;
192+
}
193+
194+
// When the user is hovering over the start of the comparison range while selecting the main
195+
// range, we need to split the background so that one half shows the main range color and the
196+
// other one shows the comparison. Since we only have one element to work with, we do it
197+
// using a gradient.
198+
.mat-calendar-body-comparison-start.mat-calendar-body-range-end::before {
199+
background: linear-gradient(to right, $range-color 50%, $comparison-color 50%);
200+
}
201+
202+
.mat-calendar-body-in-comparison-range.mat-calendar-body-in-range::after {
203+
background: $overlap-color;
204+
}
205+
206+
.mat-calendar-body-in-comparison-range.mat-calendar-body-in-range > .mat-calendar-body-selected {
207+
background: $overlap-selected-color;
208+
}
209+
}

src/material/datepicker/calendar-body.html

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,12 @@
3636
[attr.data-mat-col]="colIndex"
3737
[class.mat-calendar-body-disabled]="!item.enabled"
3838
[class.mat-calendar-body-active]="_isActiveCell(rowIndex, colIndex)"
39+
[class.mat-calendar-body-range-start]="_isRangeStart(item.compareValue)"
40+
[class.mat-calendar-body-range-end]="_isRangeEnd(item.compareValue)"
3941
[class.mat-calendar-body-in-range]="_isInRange(item.compareValue)"
40-
[class.mat-calendar-body-range-start]="item.compareValue === startValue"
41-
[class.mat-calendar-body-range-end]="item.compareValue === endValue || item.compareValue === _hoveredValue"
42+
[class.mat-calendar-body-comparison-start]="_isComparisonStart(item.compareValue)"
43+
[class.mat-calendar-body-comparison-end]="_isComparisonEnd(item.compareValue)"
44+
[class.mat-calendar-body-in-comparison-range]="_isInComparisonRange(item.compareValue)"
4245
[attr.aria-label]="item.ariaLabel"
4346
[attr.aria-disabled]="!item.enabled || null"
4447
[attr.aria-selected]="_isSelected(item)"

src/material/datepicker/calendar-body.scss

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,12 @@ $mat-calendar-range-end-body-cell-size:
4242
// hovering through all the cells look glitchy. We can't do it on the cell itself, because
4343
// it's the one that has the event listener and it can't be on the cell content, because
4444
// it always has a border radius.
45-
&::before {
45+
&::before, &::after {
4646
content: '';
4747
position: absolute;
4848
top: $mat-calendar-body-cell-content-margin;
4949
left: 0;
50+
z-index: 0;
5051

5152
// We want the range background to be slightly shorter than the cell so
5253
// that there's a gap when the range goes across multiple rows.
@@ -55,7 +56,11 @@ $mat-calendar-range-end-body-cell-size:
5556
}
5657
}
5758

58-
.mat-calendar-body-range-start::before {
59+
.mat-calendar-body-range-start:not(.mat-calendar-body-in-comparison-range)::before,
60+
.mat-calendar-body-range-start.mat-calendar-body-comparison-start::before,
61+
.mat-calendar-body-range-start::after,
62+
.mat-calendar-body-comparison-start:not(.mat-calendar-body-in-range)::before,
63+
.mat-calendar-body-comparison-start::after {
5964
// Since the range background isn't a perfect circle, we need to size
6065
// and offset the start so that it aligns with the main circle.
6166
left: $mat-calendar-body-cell-content-margin;
@@ -71,7 +76,12 @@ $mat-calendar-range-end-body-cell-size:
7176
}
7277
}
7378

74-
.mat-calendar-body-range-end::before {
79+
.mat-calendar-body-range-end:not(.mat-calendar-body-in-comparison-range)::before,
80+
.mat-calendar-body-range-end.mat-calendar-body-comparison-end::before,
81+
.mat-calendar-body-range-end::after,
82+
.mat-calendar-body-range-start.mat-calendar-body-comparison-end::before,
83+
.mat-calendar-body-comparison-end:not(.mat-calendar-body-in-range)::before,
84+
.mat-calendar-body-comparison-end:not(.mat-calendar-body-range-start)::after {
7585
// Since the range background isn't a perfect circle, we need to
7686
// resize the end so that it aligns with the main circle.
7787
width: $mat-calendar-range-end-body-cell-size;
@@ -94,6 +104,7 @@ $mat-calendar-range-end-body-cell-size:
94104
position: absolute;
95105
top: $mat-calendar-body-cell-content-margin;
96106
left: $mat-calendar-body-cell-content-margin;
107+
z-index: 1;
97108

98109
display: flex;
99110
align-items: center;

src/material/datepicker/calendar-body.ts

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
8989
*/
9090
@Input() cellAspectRatio: number = 1;
9191

92+
/** Start of the comparison range. */
93+
@Input() comparisonStart: number | null;
94+
95+
/** End of the comparison range. */
96+
@Input() comparisonEnd: number | null;
97+
9298
/** Emits when a new value is selected. */
9399
@Output() readonly selectedValueChange: EventEmitter<number> = new EventEmitter<number>();
94100

@@ -105,7 +111,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
105111
* Value that the user is either currently hovering over or is focusing
106112
* using the keyboard. Only applies when selecting the end of a date range.
107113
*/
108-
_hoveredValue: number;
114+
_hoveredValue = -1;
109115

110116
constructor(
111117
private _elementRef: ElementRef<HTMLElement>,
@@ -149,8 +155,9 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
149155
this._cellWidth = `${100 / numCols}%`;
150156
}
151157

152-
if (changes['startValue'] || changes['endValue']) {
153-
this._hoveredValue = -1;
158+
const valueChange = changes['startValue'] || changes['endValue'];
159+
if (valueChange && !valueChange.firstChange) {
160+
this._hoveredValue = this.startValue || -1;
154161
}
155162
}
156163

@@ -189,17 +196,41 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
189196
}
190197

191198
/** Gets whether the calendar is currently selecting a range. */
192-
_isRange(): boolean {
199+
_isSelectingRange(): boolean {
193200
return this.startValue !== this.endValue;
194201
}
195202

203+
/** Gets whether a value is the start of the main range. */
204+
_isRangeStart(value: number) {
205+
return value === this.startValue;
206+
}
207+
208+
/** Gets whether a value is the end of the main range. */
209+
_isRangeEnd(value: number) {
210+
return value === this.endValue || (value === this._hoveredValue && value >= this.startValue);
211+
}
212+
196213
/** Gets whether a value is within the currently-selected range. */
197214
_isInRange(value: number): boolean {
198-
if (!this._isRange() || value < this.startValue) {
199-
return false;
200-
}
215+
return this._isSelectingRange() && value >= this.startValue &&
216+
(value <= this.endValue || value <= this._hoveredValue);
217+
}
201218

202-
return value <= this.endValue || value <= this._hoveredValue;
219+
/** Gets whether a value is the start of the comparison range. */
220+
_isComparisonStart(value: number) {
221+
return value === this.comparisonStart;
222+
}
223+
224+
/** Gets whether a value is the end of the comparison range. */
225+
_isComparisonEnd(value: number) {
226+
return value === this.comparisonEnd;
227+
}
228+
229+
/** Gets whether a value is within the current comparison range. */
230+
_isInComparisonRange(value: number) {
231+
return this.comparisonStart && this.comparisonEnd &&
232+
value >= this.comparisonStart &&
233+
value <= this.comparisonEnd;
203234
}
204235

205236
/**
@@ -209,18 +240,20 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
209240
private _enterHandler = (event: Event) => {
210241
// We only need to hit the zone when we're selecting a range, we
211242
// have a start value without an end value and we've hovered over a date cell.
212-
if (!event.target || !this.startValue || this.endValue || !this._isRange()) {
243+
if (!event.target || !this.startValue || this.endValue || !this._isSelectingRange()) {
213244
return;
214245
}
215246

216247
const cell = this._getCellFromElement(event.target as HTMLElement);
217248

218249
if (cell) {
219-
this._ngZone.run(() => {
220-
this._hoveredValue =
221-
cell.enabled && cell.compareValue !== this.startValue ? cell.compareValue : -1;
222-
this._changeDetectorRef.markForCheck();
223-
});
250+
const value = cell.compareValue;
251+
const hoveredValue = cell.enabled ? value : -1;
252+
253+
if (hoveredValue !== this._hoveredValue) {
254+
this._hoveredValue = hoveredValue;
255+
this._ngZone.run(() => this._changeDetectorRef.markForCheck());
256+
}
224257
}
225258
}
226259

@@ -230,7 +263,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
230263
*/
231264
private _leaveHandler = (event: Event) => {
232265
// We only need to hit the zone when we're selecting a range.
233-
if (this._hoveredValue !== -1 && this._isRange()) {
266+
if (this._hoveredValue !== -1 && this._isSelectingRange()) {
234267
// Only reset the hovered value when leaving cells. This looks better, because
235268
// we have a gap between the cells and the rows and we don't want to remove the
236269
// range just for it to show up again when the user moves a few pixels to the side.

src/material/datepicker/calendar.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
<ng-template [cdkPortalOutlet]="_calendarHeaderPortal"></ng-template>
32

43
<div class="mat-calendar-content" [ngSwitch]="currentView" cdkMonitorSubtreeFocus tabindex="-1">
@@ -10,6 +9,8 @@
109
[maxDate]="maxDate"
1110
[minDate]="minDate"
1211
[dateClass]="dateClass"
12+
[comparisonStart]="comparisonStart"
13+
[comparisonEnd]="comparisonEnd"
1314
(selectedChange)="_dateSelected($event)"
1415
(_userSelection)="_userSelected()">
1516
</mat-month-view>

src/material/datepicker/calendar.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,12 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
250250
/** Function that can be used to add custom CSS classes to dates. */
251251
@Input() dateClass: (date: D) => MatCalendarCellCssClasses;
252252

253+
/** Start of the comparison range. */
254+
@Input() comparisonStart: D | null;
255+
256+
/** End of the comparison range. */
257+
@Input() comparisonEnd: D | null;
258+
253259
/**
254260
* Emits when the currently selected date changes.
255261
* @breaking-change 11.0.0 Emitted value to change to `D | null`.

src/material/datepicker/date-range-input.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,12 @@ export class MatDateRangeInput<D> implements MatFormFieldControl<DateRange<D>>,
184184
/** Separator text to be shown between the inputs. */
185185
@Input() separator = '–';
186186

187+
/** Start of the comparison range that should be shown in the calendar. */
188+
@Input() comparisonStart: D | null = null;
189+
190+
/** End of the comparison range that should be shown in the calendar. */
191+
@Input() comparisonEnd: D | null = null;
192+
187193
@ContentChild(MatStartDate) _startInput: MatStartDate<D>;
188194
@ContentChild(MatEndDate) _endInput: MatEndDate<D>;
189195

0 commit comments

Comments
 (0)