Skip to content

Commit a298b81

Browse files
committed
feat(datepicker): initial styling for date range (#18369)
Reworks a few things in the calendar and adds the styling for the date range. A couple of notes: * This isn't the final UX of when start/end ranges will be selected. I wanted to have most of the functionality in place before playing around with the UX. * This approach currently has an issue where the range won't go from one month into another properly. The problem is that the date cells store the current day internally. I'll make a follow-up PR that'll address the issue by storing the timestamp instead.
1 parent 2c1b2d1 commit a298b81

16 files changed

+303
-63
lines changed

src/material/core/datetime/date-selection-model.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,16 @@ export class MatRangeDateSelectionModel<D> extends MatDateSelectionModel<DateRan
149149
start = date;
150150
} else if (end == null) {
151151
end = date;
152-
} else {
153-
start = date;
154-
end = null;
152+
} else if (date) {
153+
if (this.adapter.compareDate(date, start) <= 0) {
154+
start = date;
155+
156+
if (end) {
157+
end = null;
158+
}
159+
} else {
160+
end = date;
161+
}
155162
}
156163

157164
super.updateSelection(new DateRange<D>(start, end), this);

src/material/datepicker/_datepicker-theme.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
$mat-datepicker-selected-today-box-shadow-width: 1px;
88
$mat-datepicker-selected-fade-amount: 0.6;
9+
$mat-datepicker-range-fade-amount: 0.2;
910
$mat-datepicker-today-fade-amount: 0.2;
1011
$mat-calendar-body-font-size: 13px !default;
1112
$mat-calendar-weekday-table-font-size: 11px !default;
@@ -16,6 +17,10 @@ $mat-calendar-weekday-table-font-size: 11px !default;
1617
color: mat-color($palette, default-contrast);
1718
}
1819

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

src/material/datepicker/calendar-body.html

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,23 @@
3232
class="mat-calendar-body-cell mat-focus-indicator"
3333
[ngClass]="item.cssClasses"
3434
[tabindex]="_isActiveCell(rowIndex, colIndex) ? 0 : -1"
35+
[attr.data-mat-row]="rowIndex"
36+
[attr.data-mat-col]="colIndex"
3537
[class.mat-calendar-body-disabled]="!item.enabled"
3638
[class.mat-calendar-body-active]="_isActiveCell(rowIndex, colIndex)"
39+
[class.mat-calendar-body-in-range]="_isInRange(item.value)"
40+
[class.mat-calendar-body-range-start]="item.value === startValue"
41+
[class.mat-calendar-body-range-end]="item.value === endValue || item.value === _hoveredValue"
3742
[attr.aria-label]="item.ariaLabel"
3843
[attr.aria-disabled]="!item.enabled || null"
39-
[attr.aria-selected]="selectedValue === item.value"
44+
[attr.aria-selected]="_isSelected(item)"
4045
(click)="_cellClicked(item)"
4146
[style.width]="_cellWidth"
4247
[style.paddingTop]="_cellPadding"
4348
role="button"
4449
[style.paddingBottom]="_cellPadding">
4550
<div class="mat-calendar-body-cell-content"
46-
[class.mat-calendar-body-selected]="selectedValue === item.value"
51+
[class.mat-calendar-body-selected]="_isSelected(item)"
4752
[class.mat-calendar-body-today]="todayValue === item.value">
4853
{{item.displayValue}}
4954
</div>

src/material/datepicker/calendar-body.scss

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ $mat-calendar-body-label-side-padding: 33% / 7 !default;
99
$mat-calendar-body-cell-min-size: 32px !default;
1010
$mat-calendar-body-cell-content-margin: 5% !default;
1111
$mat-calendar-body-cell-content-border-width: 1px !default;
12+
$mat-calendar-body-cell-radius: 999px !default;
1213

1314
$mat-calendar-body-min-size: 7 * $mat-calendar-body-cell-min-size !default;
1415
$mat-calendar-body-cell-content-size: 100% - $mat-calendar-body-cell-content-margin * 2 !default;
@@ -33,6 +34,20 @@ $mat-calendar-body-cell-content-size: 100% - $mat-calendar-body-cell-content-mar
3334
text-align: center;
3435
outline: none;
3536
cursor: pointer;
37+
38+
// We use ::before to apply a background to the body cell, because we need to apply a border
39+
// radius to the start/end which means that part of the element will be cut off, making
40+
// hovering through all the cells look glitchy. We can't do it on the cell itself, because
41+
// it's the one that has the event listener and it can't be on the cell content, because
42+
// it always has a border radius.
43+
&::before {
44+
content: '';
45+
position: absolute;
46+
top: 0;
47+
bottom: 0;
48+
left: 0;
49+
right: 0;
50+
}
3651
}
3752

3853
.mat-calendar-body-disabled {
@@ -59,13 +74,22 @@ $mat-calendar-body-cell-content-size: 100% - $mat-calendar-body-cell-content-mar
5974
border-style: solid;
6075

6176
// Choosing a value clearly larger than the height ensures we get the correct capsule shape.
62-
border-radius: 999px;
77+
border-radius: $mat-calendar-body-cell-radius;
6378

6479
@include cdk-high-contrast(active, off) {
6580
border: none;
6681
}
6782
}
6883

84+
.mat-calendar-body-range-start::before {
85+
border-top-left-radius: $mat-calendar-body-cell-radius;
86+
border-bottom-left-radius: $mat-calendar-body-cell-radius;
87+
}
88+
89+
.mat-calendar-body-range-end::before {
90+
border-top-right-radius: $mat-calendar-body-cell-radius;
91+
border-bottom-right-radius: $mat-calendar-body-cell-radius;
92+
}
6993

7094
@include cdk-high-contrast(active, off) {
7195
.mat-datepicker-popup:not(:empty),

src/material/datepicker/calendar-body.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ describe('MatCalendarBody', () => {
121121
[label]="label"
122122
[rows]="rows"
123123
[todayValue]="todayValue"
124-
[selectedValue]="selectedValue"
124+
[startValue]="selectedValue"
125+
[endValue]="selectedValue"
125126
[labelMinRequiredCells]="labelMinRequiredCells"
126127
[numCols]="numCols"
127128
[activeCell]="10"

src/material/datepicker/calendar-body.ts

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
NgZone,
1818
OnChanges,
1919
SimpleChanges,
20+
OnDestroy,
21+
ChangeDetectorRef,
2022
} from '@angular/core';
2123
import {take} from 'rxjs/operators';
2224

@@ -55,7 +57,7 @@ export class MatCalendarCell {
5557
encapsulation: ViewEncapsulation.None,
5658
changeDetection: ChangeDetectionStrategy.OnPush,
5759
})
58-
export class MatCalendarBody implements OnChanges {
60+
export class MatCalendarBody implements OnChanges, OnDestroy {
5961
/** The label for the table. (e.g. "Jan 2017"). */
6062
@Input() label: string;
6163

@@ -66,7 +68,11 @@ export class MatCalendarBody implements OnChanges {
6668
@Input() todayValue: number;
6769

6870
/** The value in the table that is currently selected. */
69-
@Input() selectedValue: number;
71+
// @Input() selectedValue: number;
72+
73+
@Input() startValue: number;
74+
75+
@Input() endValue: number;
7076

7177
/** The minimum number of free cells needed to fit the label in the first row. */
7278
@Input() labelMinRequiredCells: number;
@@ -95,14 +101,34 @@ export class MatCalendarBody implements OnChanges {
95101
/** Width of an individual cell. */
96102
_cellWidth: string;
97103

98-
constructor(private _elementRef: ElementRef<HTMLElement>, private _ngZone: NgZone) { }
104+
_hoveredValue: number;
105+
106+
constructor(
107+
private _elementRef: ElementRef<HTMLElement>,
108+
private _changeDetectorRef: ChangeDetectorRef,
109+
private _ngZone: NgZone) {
110+
111+
_ngZone.runOutsideAngular(() => {
112+
const element = _elementRef.nativeElement;
113+
element.addEventListener('mouseenter', this._enterHandler, true);
114+
element.addEventListener('focus', this._enterHandler, true);
115+
element.addEventListener('mouseleave', this._leaveHandler, true);
116+
element.addEventListener('blur', this._leaveHandler, true);
117+
});
118+
}
99119

120+
/** Called when a cell is clicked. */
100121
_cellClicked(cell: MatCalendarCell): void {
101122
if (cell.enabled) {
102123
this.selectedValueChange.emit(cell.value);
103124
}
104125
}
105126

127+
/** Returns whether a cell should be marked as selected. */
128+
_isSelected(cell: MatCalendarCell) {
129+
return this.startValue === cell.value || this.endValue === cell.value;
130+
}
131+
106132
ngOnChanges(changes: SimpleChanges) {
107133
const columnChanges = changes['numCols'];
108134
const {rows, numCols} = this;
@@ -120,6 +146,15 @@ export class MatCalendarBody implements OnChanges {
120146
}
121147
}
122148

149+
ngOnDestroy() {
150+
const element = this._elementRef.nativeElement;
151+
element.removeEventListener('mouseenter', this._enterHandler, true);
152+
element.removeEventListener('focus', this._enterHandler, true);
153+
element.removeEventListener('mouseleave', this._leaveHandler, true);
154+
element.removeEventListener('blur', this._leaveHandler, true);
155+
}
156+
157+
/** Returns whether a cell is active. */
123158
_isActiveCell(rowIndex: number, colIndex: number): boolean {
124159
let cellNumber = rowIndex * this.numCols + colIndex;
125160

@@ -144,4 +179,86 @@ export class MatCalendarBody implements OnChanges {
144179
});
145180
});
146181
}
182+
183+
/** Gets whether the calendar is currently selecting a range. */
184+
_isRange(): boolean {
185+
return this.startValue !== this.endValue;
186+
}
187+
188+
/** Gets whether a value is within the currently-selected range. */
189+
_isInRange(value: number): boolean {
190+
if (!this._isRange() || value < this.startValue) {
191+
return false;
192+
}
193+
194+
return value <= this.endValue || value <= this._hoveredValue;
195+
}
196+
197+
/**
198+
* Event handler for when the user enters an element
199+
* inside the calendar body (e.g. by hovering in or focus).
200+
*/
201+
private _enterHandler = (event: Event) => {
202+
// We only need to hit the zone when we're selecting a range, we
203+
// have a start value without an end value and we've hovered over a date cell.
204+
if (!event.target || !this._isRange() || !this.startValue || this.endValue) {
205+
return;
206+
}
207+
208+
const cell = this._getCellFromElement(event.target as HTMLElement);
209+
210+
if (cell) {
211+
this._ngZone.run(() => {
212+
this._hoveredValue = cell.value;
213+
this._changeDetectorRef.markForCheck();
214+
});
215+
}
216+
}
217+
218+
/**
219+
* Event handler for when the user's pointer leaves an element
220+
* inside the calendar body (e.g. by hovering out or blurring).
221+
*/
222+
private _leaveHandler = (event: Event) => {
223+
// We only need to hit the zone when we're selecting a range.
224+
if (this._isRange() && this._hoveredValue !== -1) {
225+
// Only reset the hovered value when leaving cells. This looks better, because
226+
// we have a gap between the cells and the rows and we don't want to remove the
227+
// range just for it to show up again when the user moves a few pixels to the side.
228+
if (event.target && isTableCell(event.target as HTMLElement)) {
229+
this._ngZone.run(() => {
230+
this._hoveredValue = -1;
231+
this._changeDetectorRef.markForCheck();
232+
});
233+
}
234+
}
235+
}
236+
237+
/** Finds the MatCalendarCell that corresponds to a DOM node. */
238+
private _getCellFromElement(element: HTMLElement): MatCalendarCell | null {
239+
let cell: HTMLElement | undefined;
240+
241+
if (isTableCell(element)) {
242+
cell = element;
243+
} else if (isTableCell(element.parentNode!)) {
244+
cell = element.parentNode as HTMLElement;
245+
}
246+
247+
if (cell) {
248+
const row = cell.getAttribute('data-mat-row');
249+
const col = cell.getAttribute('data-mat-col');
250+
251+
if (row && col) {
252+
return this.rows[parseInt(row)][parseInt(col)];
253+
}
254+
}
255+
256+
return null;
257+
}
258+
259+
}
260+
261+
/** Checks whether a node is a table cell element. */
262+
function isTableCell(node: Node): node is HTMLTableCellElement {
263+
return node.nodeName === 'TD';
147264
}

src/material/datepicker/calendar.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
MatDateFormats,
3232
MatDateSelectionModel,
3333
MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER,
34+
DateRange,
3435
} from '@angular/material/core';
3536
import {Subject, Subscription} from 'rxjs';
3637
import {MatCalendarCellCssClasses} from './calendar-body';
@@ -217,10 +218,14 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
217218

218219
/** The currently selected date. */
219220
@Input()
220-
get selected(): D | null { return this._model.selection; }
221-
set selected(value: D | null) {
222-
const newValue = this._getValidDateOrNull(this._dateAdapter.deserialize(value));
223-
this._model.updateSelection(newValue, this);
221+
get selected(): DateRange<D> | D | null { return this._model.selection; }
222+
set selected(value: DateRange<D> | D | null) {
223+
if (value instanceof DateRange) {
224+
this._model.updateSelection(value, this);
225+
} else {
226+
const newValue = this._getValidDateOrNull(this._dateAdapter.deserialize(value));
227+
this._model.updateSelection(newValue!, this);
228+
}
224229
}
225230

226231
/** The minimum selectable date. */
@@ -305,7 +310,7 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
305310
@Optional() private _dateAdapter: DateAdapter<D>,
306311
@Optional() @Inject(MAT_DATE_FORMATS) private _dateFormats: MatDateFormats,
307312
private _changeDetectorRef: ChangeDetectorRef,
308-
private _model: MatDateSelectionModel<D | null, D>) {
313+
private _model: MatDateSelectionModel<DateRange<D> | D | null>) {
309314

310315
if (!this._dateAdapter) {
311316
throw createMissingDateImplError('DateAdapter');
@@ -322,8 +327,10 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
322327

323328
this._selectedChanges = _model.selectionChanged.subscribe(event => {
324329
// @breaking-change 11.0.0 Remove null check once `event.selection` is allowed to be null.
330+
// Also remove non-null assertion for `selection`.
325331
if (event.selection) {
326-
this.selectedChange.emit(event.selection);
332+
const selection = this._model.selection;
333+
this.selectedChange.emit(selection instanceof DateRange ? selection.start! : selection!);
327334
}
328335
});
329336
}

src/material/datepicker/datepicker-base.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,11 @@ export class MatDatepickerContent<S, D = ExtractDateTypeFromSelection<S>>
130130
constructor(
131131
elementRef: ElementRef,
132132
/**
133-
* @deprecated `_changeDetectorRef` parameter to become required.
133+
* @deprecated `_changeDetectorRef` and `_model` parameters to become required.
134134
* @breaking-change 11.0.0
135135
*/
136-
private _changeDetectorRef?: ChangeDetectorRef) {
136+
private _changeDetectorRef?: ChangeDetectorRef,
137+
private _model?: MatDateSelectionModel<S, D>) {
137138
super(elementRef);
138139
}
139140

@@ -145,6 +146,13 @@ export class MatDatepickerContent<S, D = ExtractDateTypeFromSelection<S>>
145146
this._animationDone.complete();
146147
}
147148

149+
_handleUserSelection() {
150+
// @breaking-change 11.0.0 Remove null check for _model.
151+
if (!this._model || this._model.isComplete()) {
152+
this.datepicker.close();
153+
}
154+
}
155+
148156
_startExitAnimation() {
149157
this._animationState = 'void';
150158

src/material/datepicker/datepicker-content.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@
1111
[@fadeInCalendar]="'enter'"
1212
(yearSelected)="datepicker._selectYear($event)"
1313
(monthSelected)="datepicker._selectMonth($event)"
14-
(_userSelection)="datepicker.close()">
14+
(_userSelection)="_handleUserSelection()">
1515
</mat-calendar>

src/material/datepicker/month-view.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
[label]="_monthLabel"
1010
[rows]="_weeks"
1111
[todayValue]="_todayDate!"
12-
[selectedValue]="_selectedDate!"
12+
[startValue]="_rangeStart!"
13+
[endValue]="_rangeEnd!"
1314
[labelMinRequiredCells]="3"
1415
[activeCell]="_dateAdapter.getDate(activeDate) - 1"
1516
(selectedValueChange)="_dateSelected($event)"

0 commit comments

Comments
 (0)