Skip to content

Commit 2c9e216

Browse files
committed
feat(datepicker): allow for the preview range logic to be customized
Moves some things around so that the preview range can be controlled through the `MatCalendarRangeSelectionStrategy`.
1 parent de98d9c commit 2c9e216

File tree

9 files changed

+197
-75
lines changed

9 files changed

+197
-75
lines changed

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

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,18 +92,43 @@ export class PreserveRangeStrategy<D> implements MatCalendarRangeSelectionStrate
9292
selectionFinished(date: D, currentRange: DateRange<D>) {
9393
let {start, end} = currentRange;
9494

95+
if (start && end) {
96+
return this._getRangeRelativeToDate(date, start, end);
97+
}
98+
9599
if (start == null) {
96100
start = date;
97101
} else if (end == null) {
98102
end = date;
99-
} else if (this._dateAdapter.compareDate(start, date) > 0) {
100-
start = date;
101-
} else {
102-
end = date;
103103
}
104104

105105
return new DateRange<D>(start, end);
106106
}
107+
108+
createPreview(activeDate: D | null, currentRange: DateRange<D>): DateRange<D> {
109+
if (activeDate) {
110+
if (currentRange.start && currentRange.end) {
111+
return this._getRangeRelativeToDate(activeDate, currentRange.start, currentRange.end);
112+
} else if (currentRange.start && !currentRange.end) {
113+
return new DateRange(currentRange.start, activeDate);
114+
}
115+
}
116+
117+
return new DateRange<D>(null, null);
118+
}
119+
120+
private _getRangeRelativeToDate(date: D | null, start: D, end: D): DateRange<D> {
121+
let rangeStart: D | null = null;
122+
let rangeEnd: D | null = null;
123+
124+
if (date) {
125+
const delta = Math.round(Math.abs(this._dateAdapter.compareDate(start, end)) / 2);
126+
rangeStart = this._dateAdapter.addCalendarDays(date, -delta);
127+
rangeEnd = this._dateAdapter.addCalendarDays(date, delta);
128+
}
129+
130+
return new DateRange(rangeStart, rangeEnd);
131+
}
107132
}
108133

109134
@Directive({

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

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ describe('MatCalendarBody', () => {
194194

195195
it('should not mark a cell as a start bridge if there is no end range value', () => {
196196
testComponent.startValue = 1;
197-
testComponent.endValue = undefined;
197+
testComponent.endValue = null;
198198
testComponent.comparisonStart = 5;
199199
testComponent.comparisonEnd = 10;
200200
fixture.detectChanges();
@@ -217,7 +217,7 @@ describe('MatCalendarBody', () => {
217217
testComponent.comparisonStart = 1;
218218
testComponent.comparisonEnd = 5;
219219
testComponent.startValue = 5;
220-
testComponent.endValue = undefined;
220+
testComponent.endValue = null;
221221
fixture.detectChanges();
222222

223223
expect(cells.some(cell => cell.classList.contains(bridgeEnd))).toBe(false);
@@ -375,7 +375,7 @@ describe('MatCalendarBody', () => {
375375
});
376376

377377
it('should not show a range if there is no start', () => {
378-
testComponent.startValue = undefined;
378+
testComponent.startValue = null;
379379
testComponent.endValue = 10;
380380
fixture.detectChanges();
381381

@@ -384,7 +384,7 @@ describe('MatCalendarBody', () => {
384384
});
385385

386386
it('should not show a comparison range if there is no start', () => {
387-
testComponent.comparisonStart = undefined;
387+
testComponent.comparisonStart = null;
388388
testComponent.comparisonEnd = 10;
389389
fixture.detectChanges();
390390

@@ -394,7 +394,7 @@ describe('MatCalendarBody', () => {
394394

395395
it('should not show a comparison range if there is no end', () => {
396396
testComponent.comparisonStart = 10;
397-
testComponent.comparisonEnd = undefined;
397+
testComponent.comparisonEnd = null;
398398
fixture.detectChanges();
399399

400400
expect(cells.some(cell => cell.classList.contains(inComparisonClass))).toBe(false);
@@ -599,20 +599,26 @@ class StandardCalendarBody {
599599
@Component({
600600
template: `
601601
<table mat-calendar-body
602+
[isRange]="true"
602603
[rows]="rows"
603604
[startValue]="startValue"
604605
[endValue]="endValue"
605606
[comparisonStart]="comparisonStart"
606607
[comparisonEnd]="comparisonEnd"
607-
(selectedValueChange)="onSelect($event)">
608+
[previewStart]="previewStart"
609+
[previewEnd]="previewEnd"
610+
(selectedValueChange)="onSelect($event)"
611+
(previewChange)="previewChanged($event)">
608612
</table>`,
609613
})
610614
class RangeCalendarBody {
611615
rows = createCalendarCells(4);
612-
startValue: number | undefined;
613-
endValue: number | undefined;
614-
comparisonStart: number | undefined;
615-
comparisonEnd: number | undefined;
616+
startValue: number | null;
617+
endValue: number | null;
618+
comparisonStart: number | null;
619+
comparisonEnd: number | null;
620+
previewStart: number | null;
621+
previewEnd: number | null;
616622

617623
onSelect(event: MatCalendarUserEvent<number>) {
618624
const value = event.value;
@@ -622,9 +628,14 @@ class RangeCalendarBody {
622628
this.endValue = value;
623629
} else {
624630
this.startValue = value;
625-
this.endValue = undefined;
631+
this.endValue = null;
626632
}
627633
}
634+
635+
previewChanged(event: MatCalendarUserEvent<MatCalendarCell<Date> | null>) {
636+
this.previewStart = this.startValue;
637+
this.previewEnd = event.value?.compareValue || null;
638+
}
628639
}
629640

630641
/**

src/material/datepicker/calendar-body.ts

Lines changed: 46 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,14 @@ export type MatCalendarCellCssClasses = string | string[] | Set<string> | {[key:
3131
* An internal class that represents the data corresponding to a single calendar cell.
3232
* @docs-private
3333
*/
34-
export class MatCalendarCell {
34+
export class MatCalendarCell<D = any> {
3535
constructor(public value: number,
3636
public displayValue: string,
3737
public ariaLabel: string,
3838
public enabled: boolean,
3939
public cssClasses: MatCalendarCellCssClasses = {},
40-
public compareValue = value) {}
40+
public compareValue = value,
41+
public rawValue?: D) {}
4142
}
4243

4344
/** Event emitted when a date inside the calendar is triggered as a result of a user action. */
@@ -64,6 +65,12 @@ export interface MatCalendarUserEvent<D> {
6465
changeDetection: ChangeDetectionStrategy.OnPush,
6566
})
6667
export class MatCalendarBody implements OnChanges, OnDestroy {
68+
/**
69+
* Used to skip the next focus event when rendering the preview range.
70+
* We need a flag like this, because some browsers fire focus events asynchronously.
71+
*/
72+
private _skipNextFocus: boolean;
73+
6774
/** The label for the table. (e.g. "Jan 2017"). */
6875
@Input() label: string;
6976

@@ -88,6 +95,9 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
8895
/** The cell number of the active cell in the table. */
8996
@Input() activeCell: number = 0;
9097

98+
/** Whether a range is being selected. */
99+
@Input() isRange: boolean = false;
100+
91101
/**
92102
* The aspect ratio (width / height) to use for the cells in the table. This aspect ratio will be
93103
* maintained even as the table resizes.
@@ -100,10 +110,19 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
100110
/** End of the comparison range. */
101111
@Input() comparisonEnd: number | null;
102112

113+
/** Start of the preview range. */
114+
@Input() previewStart: number | null = null;
115+
116+
/** End of the preview range. */
117+
@Input() previewEnd: number | null = null;
118+
103119
/** Emits when a new value is selected. */
104120
@Output() readonly selectedValueChange: EventEmitter<MatCalendarUserEvent<number>> =
105121
new EventEmitter<MatCalendarUserEvent<number>>();
106122

123+
/** Emits when the preview has changed as a result of a user action. */
124+
@Output() previewChange = new EventEmitter<MatCalendarUserEvent<MatCalendarCell | null>>();
125+
107126
/** The number of blank cells to put at the beginning for the first row. */
108127
_firstRowOffset: number;
109128

@@ -113,12 +132,6 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
113132
/** Width of an individual cell. */
114133
_cellWidth: string;
115134

116-
/**
117-
* Value that the user is either currently hovering over or is focusing
118-
* using the keyboard. Only applies when selecting the end of a date range.
119-
*/
120-
_previewEnd = -1;
121-
122135
constructor(
123136
private _elementRef: ElementRef<HTMLElement>,
124137
private _changeDetectorRef: ChangeDetectorRef,
@@ -160,10 +173,6 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
160173
if (columnChanges || !this._cellWidth) {
161174
this._cellWidth = `${100 / numCols}%`;
162175
}
163-
164-
if (changes['startValue'] || changes['endValue']) {
165-
this._previewEnd = -1;
166-
}
167176
}
168177

169178
ngOnDestroy() {
@@ -187,24 +196,23 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
187196
}
188197

189198
/** Focuses the active cell after the microtask queue is empty. */
190-
_focusActiveCell() {
199+
_focusActiveCell(movePreview = true) {
191200
this._ngZone.runOutsideAngular(() => {
192201
this._ngZone.onStable.asObservable().pipe(take(1)).subscribe(() => {
193202
const activeCell: HTMLElement | null =
194203
this._elementRef.nativeElement.querySelector('.mat-calendar-body-active');
195204

196205
if (activeCell) {
206+
if (!movePreview) {
207+
this._skipNextFocus = true;
208+
}
209+
197210
activeCell.focus();
198211
}
199212
});
200213
});
201214
}
202215

203-
/** Gets whether the calendar is currently selecting a range. */
204-
_isSelectingRange(): boolean {
205-
return this.startValue !== this.endValue;
206-
}
207-
208216
/** Gets whether a value is the start of the main range. */
209217
_isRangeStart(value: number) {
210218
return value === this.startValue;
@@ -217,7 +225,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
217225

218226
/** Gets whether a value is within the currently-selected range. */
219227
_isInRange(value: number): boolean {
220-
return this._isSelectingRange() && value >= this.startValue && value <= this.endValue;
228+
return this.isRange && this.startValue !== null && this.endValue !== null &&
229+
value >= this.startValue && value <= this.endValue;
221230
}
222231

223232
/** Gets whether a value is the start of the comparison range. */
@@ -272,41 +281,41 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
272281

273282
/** Gets whether a value is the start of the preview range. */
274283
_isPreviewStart(value: number) {
275-
return this._previewEnd > -1 && this._isRangeStart(value);
284+
return value === this.previewStart && this.previewEnd && value < this.previewEnd;
276285
}
277286

278287
/** Gets whether a value is the end of the preview range. */
279288
_isPreviewEnd(value: number) {
280-
return value === this._previewEnd;
289+
return value === this.previewEnd && this.previewStart && value > this.previewStart;
281290
}
282291

283292
/** Gets whether a value is inside the preview range. */
284293
_isInPreview(value: number) {
285-
return this._isSelectingRange() && value >= this.startValue && value <= this._previewEnd;
294+
if (!this.isRange) {
295+
return false;
296+
}
297+
298+
const {previewStart, previewEnd} = this;
299+
return previewStart !== null && previewEnd !== null && previewStart !== previewEnd &&
300+
value >= previewStart && value <= previewEnd;
286301
}
287302

288303
/**
289304
* Event handler for when the user enters an element
290305
* inside the calendar body (e.g. by hovering in or focus).
291306
*/
292307
private _enterHandler = (event: Event) => {
293-
// We only need to hit the zone when we're selecting a range, we have a
294-
// start value without an end value and we've hovered over a date cell.
295-
if (!event.target || !this.startValue || this.endValue || !this._isSelectingRange()) {
308+
if (this._skipNextFocus && event.type === 'focus') {
309+
this._skipNextFocus = false;
296310
return;
297311
}
298312

299-
const cell = this._getCellFromElement(event.target as HTMLElement);
300-
301-
if (cell) {
302-
const value = cell.compareValue;
303-
304-
// Only set as the preview end value if we're after the start of the range.
305-
const previewEnd = (cell.enabled && value > this.startValue) ? value : -1;
313+
// We only need to hit the zone when we're selecting a range.
314+
if (event.target && this.isRange) {
315+
const cell = this._getCellFromElement(event.target as HTMLElement);
306316

307-
if (previewEnd !== this._previewEnd) {
308-
this._previewEnd = previewEnd;
309-
this._ngZone.run(() => this._changeDetectorRef.markForCheck());
317+
if (cell) {
318+
this._ngZone.run(() => this.previewChange.emit({value: cell.enabled ? cell : null, event}));
310319
}
311320
}
312321
}
@@ -317,13 +326,13 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
317326
*/
318327
private _leaveHandler = (event: Event) => {
319328
// We only need to hit the zone when we're selecting a range.
320-
if (this._previewEnd !== -1 && this._isSelectingRange()) {
329+
if (this.previewEnd !== null && this.isRange) {
321330
// Only reset the preview end value when leaving cells. This looks better, because
322331
// we have a gap between the cells and the rows and we don't want to remove the
323332
// range just for it to show up again when the user moves a few pixels to the side.
324333
if (event.target && isTableCell(event.target as HTMLElement)) {
325334
this._ngZone.run(() => {
326-
this._previewEnd = -1;
335+
this.previewChange.emit({value: null, event});
327336

328337
// Note that here we need to use `detectChanges`, rather than `markForCheck`, because
329338
// the way `_focusActiveCell` is set up at the moment makes it fire at the wrong time

src/material/datepicker/calendar-range-selection-strategy.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,26 @@ export const MAT_CALENDAR_RANGE_SELECTION_STRATEGY =
1919

2020
/** Object that can be provided in order to customize the date range selection behavior. */
2121
export interface MatCalendarRangeSelectionStrategy<D> {
22-
/** Called when the user has finished selecting a value. */
22+
/**
23+
* Called when the user has finished selecting a value.
24+
* @param date Date that was selected. Will be null if the user cleared the selection.
25+
* @param currentRange Range that is currently show in the calendar.
26+
* @param event DOM event that triggered the selection. Currently only corresponds to a `click`
27+
* event, but it may get expanded in the future.
28+
*/
2329
selectionFinished(date: D | null, currentRange: DateRange<D>, event: Event): DateRange<D>;
30+
31+
/**
32+
* Called when the user has activated a new date (e.g. by hovering over
33+
* it or moving focus) and the calendar tries to display a date range.
34+
*
35+
* @param activeDate Date that the user has activated. Will be null if the user moved
36+
* focus to an element that's no a calendar cell.
37+
* @param currentRange Range that is currently shown in the calendar.
38+
* @param event DOM event that caused the preview to be changed. Will be either a
39+
* `mouseenter`/`mouseleave` or `focus`/`blur` depending on how the user is navigating.
40+
*/
41+
createPreview(activeDate: D | null, currentRange: DateRange<D>, event: Event): DateRange<D>;
2442
}
2543

2644
/** Provides the default date range selection behavior. */
@@ -42,4 +60,16 @@ export class DefaultMatCalendarRangeStrategy<D> implements MatCalendarRangeSelec
4260

4361
return new DateRange<D>(start, end);
4462
}
63+
64+
createPreview(activeDate: D | null, currentRange: DateRange<D>) {
65+
let start: D | null = null;
66+
let end: D | null = null;
67+
68+
if (currentRange.start && !currentRange.end && activeDate) {
69+
start = currentRange.start;
70+
end = activeDate;
71+
}
72+
73+
return new DateRange<D>(start, end);
74+
}
4575
}

src/material/datepicker/calendar.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
389389
}
390390

391391
focusActiveCell() {
392-
this._getCurrentViewComponent()._focusActiveCell();
392+
this._getCurrentViewComponent()._focusActiveCell(false);
393393
}
394394

395395
/** Updates today's date after an update of the active date */

0 commit comments

Comments
 (0)