Skip to content

Commit 6328ef5

Browse files
authored
feat(datepicker): implement comparison and overlap ranges (#18753)
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 6328ef5

17 files changed

+276
-50
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: 36 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,36 @@ $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+
.mat-calendar-body-in-range::before {
177+
background: $range-color;
178+
}
179+
180+
.mat-calendar-body-in-comparison-range::before {
181+
background: $comparison-color;
182+
}
183+
184+
.mat-calendar-body-comparison-bridge-start::before,
185+
[dir='rtl'] .mat-calendar-body-comparison-bridge-end::before {
186+
background: linear-gradient(to right, $range-color 50%, $comparison-color 50%);
187+
}
188+
189+
.mat-calendar-body-comparison-bridge-end::before,
190+
[dir='rtl'] .mat-calendar-body-comparison-bridge-start::before {
191+
background: linear-gradient(to left, $range-color 50%, $comparison-color 50%);
192+
}
193+
194+
.mat-calendar-body-in-comparison-range.mat-calendar-body-in-range::after {
195+
background: $overlap-color;
196+
}
197+
198+
.mat-calendar-body-in-comparison-range > .mat-calendar-body-selected {
199+
background: $overlap-selected-color;
200+
}
201+
}

src/material/datepicker/calendar-body.html

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,14 @@
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-bridge-start]="_isComparisonBridgeStart(item.compareValue, rowIndex, colIndex)"
43+
[class.mat-calendar-body-comparison-bridge-end]="_isComparisonBridgeEnd(item.compareValue, rowIndex, colIndex)"
44+
[class.mat-calendar-body-comparison-start]="_isComparisonStart(item.compareValue)"
45+
[class.mat-calendar-body-comparison-end]="_isComparisonEnd(item.compareValue)"
46+
[class.mat-calendar-body-in-comparison-range]="_isInComparisonRange(item.compareValue)"
4247
[attr.aria-label]="item.ariaLabel"
4348
[attr.aria-disabled]="!item.enabled || null"
4449
[attr.aria-selected]="_isSelected(item)"

src/material/datepicker/calendar-body.scss

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,26 +36,32 @@ $mat-calendar-range-end-body-cell-size:
3636
text-align: center;
3737
outline: none;
3838
cursor: pointer;
39+
}
3940

41+
.mat-calendar-body-cell::before,
42+
.mat-calendar-body-cell::after,
43+
.mat-calendar-body-comparison-bridge-start::before {
4044
// We use ::before to apply a background to the body cell, because we need to apply a border
4145
// radius to the start/end which means that part of the element will be cut off, making
4246
// hovering through all the cells look glitchy. We can't do it on the cell itself, because
4347
// it's the one that has the event listener and it can't be on the cell content, because
4448
// it always has a border radius.
45-
&::before {
46-
content: '';
47-
position: absolute;
48-
top: $mat-calendar-body-cell-content-margin;
49-
left: 0;
49+
content: '';
50+
position: absolute;
51+
top: $mat-calendar-body-cell-content-margin;
52+
left: 0;
53+
z-index: 0;
5054

51-
// We want the range background to be slightly shorter than the cell so
52-
// that there's a gap when the range goes across multiple rows.
53-
height: $mat-calendar-body-cell-content-size;
54-
width: 100%;
55-
}
55+
// We want the range background to be slightly shorter than the cell so
56+
// that there's a gap when the range goes across multiple rows.
57+
height: $mat-calendar-body-cell-content-size;
58+
width: 100%;
5659
}
5760

58-
.mat-calendar-body-range-start::before {
61+
.mat-calendar-body-range-start:not(.mat-calendar-body-in-comparison-range)::before,
62+
.mat-calendar-body-range-start::after,
63+
.mat-calendar-body-comparison-start:not(.mat-calendar-body-comparison-bridge-start)::before,
64+
.mat-calendar-body-comparison-start::after {
5965
// Since the range background isn't a perfect circle, we need to size
6066
// and offset the start so that it aligns with the main circle.
6167
left: $mat-calendar-body-cell-content-margin;
@@ -71,12 +77,19 @@ $mat-calendar-range-end-body-cell-size:
7177
}
7278
}
7379

74-
.mat-calendar-body-range-end::before {
80+
@mixin _mat-calendar-body-range-right-radius {
7581
// Since the range background isn't a perfect circle, we need to
7682
// resize the end so that it aligns with the main circle.
7783
width: $mat-calendar-range-end-body-cell-size;
7884
border-top-right-radius: $mat-calendar-body-cell-radius;
7985
border-bottom-right-radius: $mat-calendar-body-cell-radius;
86+
}
87+
88+
.mat-calendar-body-range-end:not(.mat-calendar-body-in-comparison-range)::before,
89+
.mat-calendar-body-range-end::after,
90+
.mat-calendar-body-comparison-end:not(.mat-calendar-body-comparison-bridge-end)::before,
91+
.mat-calendar-body-comparison-end::after {
92+
@include _mat-calendar-body-range-right-radius;
8093

8194
[dir='rtl'] & {
8295
left: $mat-calendar-body-cell-content-margin;
@@ -86,6 +99,15 @@ $mat-calendar-range-end-body-cell-size:
8699
}
87100
}
88101

102+
[dir='rtl'] {
103+
.mat-calendar-body-comparison-bridge-start.mat-calendar-body-range-end::after,
104+
.mat-calendar-body-comparison-bridge-end.mat-calendar-body-range-start::after,
105+
.mat-calendar-body-range-start.mat-calendar-body-range-end::before,
106+
.mat-calendar-body-range-start.mat-calendar-body-range-end::after {
107+
@include _mat-calendar-body-range-right-radius;
108+
}
109+
}
110+
89111
.mat-calendar-body-disabled {
90112
cursor: default;
91113
}
@@ -94,6 +116,7 @@ $mat-calendar-range-end-body-cell-size:
94116
position: absolute;
95117
top: $mat-calendar-body-cell-content-margin;
96118
left: $mat-calendar-body-cell-content-margin;
119+
z-index: 1;
97120

98121
display: flex;
99122
align-items: center;

src/material/datepicker/calendar-body.ts

Lines changed: 75 additions & 11 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>,
@@ -189,17 +195,73 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
189195
}
190196

191197
/** Gets whether the calendar is currently selecting a range. */
192-
_isRange(): boolean {
198+
_isSelectingRange(): boolean {
193199
return this.startValue !== this.endValue;
194200
}
195201

202+
/** Gets whether a value is the start of the main range. */
203+
_isRangeStart(value: number) {
204+
return value === this.startValue;
205+
}
206+
207+
/** Gets whether a value is the end of the main range. */
208+
_isRangeEnd(value: number) {
209+
return value === this.endValue || (value === this._hoveredValue && value >= this.startValue);
210+
}
211+
196212
/** Gets whether a value is within the currently-selected range. */
197213
_isInRange(value: number): boolean {
198-
if (!this._isRange() || value < this.startValue) {
214+
return this._isSelectingRange() && value >= this.startValue &&
215+
(value <= this.endValue || value <= this._hoveredValue);
216+
}
217+
218+
/** Gets whether a value is the start of the comparison range. */
219+
_isComparisonStart(value: number) {
220+
return value === this.comparisonStart;
221+
}
222+
223+
/** Whether the cell is a start bridge cell between the main and comparison ranges. */
224+
_isComparisonBridgeStart(value: number, rowIndex: number, colIndex: number) {
225+
if (!this._isComparisonStart(value) || this._isRangeStart(value) || !this._isInRange(value)) {
226+
return false;
227+
}
228+
229+
let previousCell: MatCalendarCell | undefined = this.rows[rowIndex][colIndex - 1];
230+
231+
if (!previousCell) {
232+
const previousRow = this.rows[rowIndex - 1];
233+
previousCell = previousRow && previousRow[previousRow.length - 1];
234+
}
235+
236+
return previousCell && !this._isRangeEnd(previousCell.compareValue);
237+
}
238+
239+
/** Whether the cell is an end bridge cell between the main and comparison ranges. */
240+
_isComparisonBridgeEnd(value: number, rowIndex: number, colIndex: number) {
241+
if (!this._isComparisonEnd(value) || this._isRangeEnd(value) || !this._isInRange(value)) {
199242
return false;
200243
}
201244

202-
return value <= this.endValue || value <= this._hoveredValue;
245+
let nextCell: MatCalendarCell | undefined = this.rows[rowIndex][colIndex + 1];
246+
247+
if (!nextCell) {
248+
const nextRow = this.rows[rowIndex + 1];
249+
nextCell = nextRow && nextRow[0];
250+
}
251+
252+
return nextCell && !this._isRangeStart(nextCell.compareValue);
253+
}
254+
255+
/** Gets whether a value is the end of the comparison range. */
256+
_isComparisonEnd(value: number) {
257+
return value === this.comparisonEnd;
258+
}
259+
260+
/** Gets whether a value is within the current comparison range. */
261+
_isInComparisonRange(value: number) {
262+
return this.comparisonStart && this.comparisonEnd &&
263+
value >= this.comparisonStart &&
264+
value <= this.comparisonEnd;
203265
}
204266

205267
/**
@@ -209,18 +271,20 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
209271
private _enterHandler = (event: Event) => {
210272
// We only need to hit the zone when we're selecting a range, we
211273
// 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()) {
274+
if (!event.target || !this.startValue || this.endValue || !this._isSelectingRange()) {
213275
return;
214276
}
215277

216278
const cell = this._getCellFromElement(event.target as HTMLElement);
217279

218280
if (cell) {
219-
this._ngZone.run(() => {
220-
this._hoveredValue =
221-
cell.enabled && cell.compareValue !== this.startValue ? cell.compareValue : -1;
222-
this._changeDetectorRef.markForCheck();
223-
});
281+
const value = cell.compareValue;
282+
const hoveredValue = cell.enabled ? value : -1;
283+
284+
if (hoveredValue !== this._hoveredValue) {
285+
this._hoveredValue = hoveredValue;
286+
this._ngZone.run(() => this._changeDetectorRef.markForCheck());
287+
}
224288
}
225289
}
226290

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

0 commit comments

Comments
 (0)