Skip to content

Commit eb75903

Browse files
committed
feat(datepicker): allow for date range selection logic to be customized
As discussed, adds a provider that allows the consumer to customize how the range is changed after the user has selected a value. For now it only has one method (`selectionFinished`), but the interface should allow us to add more later on.
1 parent ab3d915 commit eb75903

14 files changed

+166
-69
lines changed

src/material/datepicker/calendar-body.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
[attr.aria-label]="item.ariaLabel"
5151
[attr.aria-disabled]="!item.enabled || null"
5252
[attr.aria-selected]="_isSelected(item)"
53-
(click)="_cellClicked(item)"
53+
(click)="_cellClicked(item, $event)"
5454
[style.width]="_cellWidth"
5555
[style.paddingTop]="_cellPadding"
5656
role="button"

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
22
import {Component} from '@angular/core';
3-
import {MatCalendarBody, MatCalendarCell} from './calendar-body';
3+
import {MatCalendarBody, MatCalendarCell, MatCalendarUserEvent} from './calendar-body';
44
import {By} from '@angular/platform-browser';
55
import {dispatchMouseEvent, dispatchFakeEvent} from '@angular/cdk/testing/private';
66

@@ -591,8 +591,8 @@ class StandardCalendarBody {
591591
labelMinRequiredCells = 3;
592592
numCols = 7;
593593

594-
onSelect(value: number) {
595-
this.selectedValue = value;
594+
onSelect(event: MatCalendarUserEvent<number>) {
595+
this.selectedValue = event.value;
596596
}
597597
}
598598

@@ -614,7 +614,8 @@ class RangeCalendarBody {
614614
comparisonStart: number | undefined;
615615
comparisonEnd: number | undefined;
616616

617-
onSelect(value: number) {
617+
onSelect(event: MatCalendarUserEvent<number>) {
618+
const value = event.value;
618619
if (!this.startValue) {
619620
this.startValue = value;
620621
} else if (!this.endValue) {

src/material/datepicker/calendar-body.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ export class MatCalendarCell {
4040
public compareValue = value) {}
4141
}
4242

43+
/** Event emitted when a date inside the calendar is triggered as a result of a user action. */
44+
export interface MatCalendarUserEvent<D> {
45+
value: D;
46+
event: Event;
47+
}
4348

4449
/**
4550
* An internal component used to display calendar data in a table.
@@ -96,7 +101,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
96101
@Input() comparisonEnd: number | null;
97102

98103
/** Emits when a new value is selected. */
99-
@Output() readonly selectedValueChange: EventEmitter<number> = new EventEmitter<number>();
104+
@Output() readonly selectedValueChange: EventEmitter<MatCalendarUserEvent<number>> =
105+
new EventEmitter<MatCalendarUserEvent<number>>();
100106

101107
/** The number of blank cells to put at the beginning for the first row. */
102108
_firstRowOffset: number;
@@ -128,9 +134,9 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
128134
}
129135

130136
/** Called when a cell is clicked. */
131-
_cellClicked(cell: MatCalendarCell): void {
137+
_cellClicked(cell: MatCalendarCell, event: MouseEvent): void {
132138
if (cell.enabled) {
133-
this.selectedValueChange.emit(cell.value);
139+
this.selectedValueChange.emit({value: cell.value, event});
134140
}
135141
}
136142

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Injectable, InjectionToken} from '@angular/core';
10+
import {DateAdapter} from '@angular/material/core';
11+
import {DateRange} from './date-selection-model';
12+
13+
/** Injection token used to customize the date range selection behavior. */
14+
export const MAT_CALENDAR_RANGE_SELECTION_STRATEGY =
15+
new InjectionToken<MatCalendarRangeSelectionStrategy<any>>(
16+
'MAT_CALENDAR_RANGE_SELECTION_STRATEGY');
17+
18+
/** Object that can be provided in order to customize the date range selection behavior. */
19+
export interface MatCalendarRangeSelectionStrategy<D> {
20+
/** Called when the user has finished selecting a value. */
21+
selectionFinished(date: D | null, currentRange: DateRange<D>, event: Event): DateRange<D>;
22+
}
23+
24+
/** Provides the default date range selection behavior. */
25+
@Injectable()
26+
export class DefaultMatCalendarRangeStrategy<D> implements MatCalendarRangeSelectionStrategy<D> {
27+
constructor(private _dateAdapter: DateAdapter<D>) {}
28+
29+
selectionFinished(date: D, currentRange: DateRange<D>) {
30+
let {start, end} = currentRange;
31+
32+
if (start == null) {
33+
start = date;
34+
} else if (end == null && date && this._dateAdapter.compareDate(date, start) > 0) {
35+
end = date;
36+
} else {
37+
start = date;
38+
end = null;
39+
}
40+
41+
return new DateRange<D>(start, end);
42+
}
43+
}

src/material/datepicker/calendar.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@
1111
[dateClass]="dateClass"
1212
[comparisonStart]="comparisonStart"
1313
[comparisonEnd]="comparisonEnd"
14-
(selectedChange)="_dateSelected($event)"
15-
(_userSelection)="_userSelected()">
14+
(_userSelection)="_dateSelected($event)">
1615
</mat-month-view>
1716

1817
<mat-year-view

src/material/datepicker/calendar.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
MatDateFormats,
3232
} from '@angular/material/core';
3333
import {Subject, Subscription} from 'rxjs';
34-
import {MatCalendarCellCssClasses} from './calendar-body';
34+
import {MatCalendarCellCssClasses, MatCalendarUserEvent} from './calendar-body';
3535
import {createMissingDateImplError} from './datepicker-errors';
3636
import {MatDatepickerIntl} from './datepicker-intl';
3737
import {MatMonthView} from './month-view';
@@ -47,6 +47,10 @@ import {
4747
DateRange,
4848
MatDateSelectionModel,
4949
} from './date-selection-model';
50+
import {
51+
MAT_CALENDAR_RANGE_SELECTION_STRATEGY,
52+
MatCalendarRangeSelectionStrategy
53+
} from './calendar-range-selection-strategy';
5054

5155
/**
5256
* Possible views for the calendar.
@@ -318,7 +322,9 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
318322
@Optional() private _dateAdapter: DateAdapter<D>,
319323
@Optional() @Inject(MAT_DATE_FORMATS) private _dateFormats: MatDateFormats,
320324
private _changeDetectorRef: ChangeDetectorRef,
321-
private _model: MatDateSelectionModel<DateRange<D> | D | null>) {
325+
private _model: MatDateSelectionModel<DateRange<D> | D | null>,
326+
@Optional() @Inject(MAT_CALENDAR_RANGE_SELECTION_STRATEGY)
327+
private _rangeSelectionStrategy?: MatCalendarRangeSelectionStrategy<D>) {
322328

323329
if (!this._dateAdapter) {
324330
throw createMissingDateImplError('DateAdapter');
@@ -403,8 +409,25 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
403409
}
404410

405411
/** Handles date selection in the month view. */
406-
_dateSelected(date: D | null): void {
407-
this._model.add(date);
412+
_dateSelected(event: MatCalendarUserEvent<D | null>): void {
413+
const selection = this._model.selection;
414+
const value = event.value;
415+
const isRange = selection instanceof DateRange;
416+
417+
// If we're selecting a range and we have a selection strategy, always pass the value through
418+
// there. Otherwise don't assign null values to the model, unless we're selecting a range.
419+
// A null value when picking a range means that the user cancelled the selection (e.g. by
420+
// pressing escape), whereas when selecting a single value it means that the value didn't
421+
// change. This isn't very intuitive, but it's here for backwards-compatibility.
422+
if (isRange && this._rangeSelectionStrategy) {
423+
const newSelection = this._rangeSelectionStrategy.selectionFinished(value,
424+
selection as DateRange<D>, event.event);
425+
this._model.updateSelection(newSelection, this);
426+
} else if (value || isRange) {
427+
this._model.add(value);
428+
}
429+
430+
this._userSelection.emit();
408431
}
409432

410433
/** Handles year selection in the multiyear view. */
@@ -417,10 +440,6 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
417440
this.monthSelected.emit(normalizedMonth);
418441
}
419442

420-
_userSelected(): void {
421-
this._userSelection.emit();
422-
}
423-
424443
/** Handles year/month selection in the multi-year/year views. */
425444
_goToDateInView(date: D, view: 'month' | 'year' | 'multi-year'): void {
426445
this.activeDate = date;

src/material/datepicker/date-selection-model.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
*/
88

99
import {FactoryProvider, Injectable, Optional, SkipSelf, OnDestroy} from '@angular/core';
10-
import {DateAdapter} from '@angular/material/core';
1110
import {Observable, Subject} from 'rxjs';
1211

1312
/** A class representing a range of dates. */
@@ -50,8 +49,6 @@ export abstract class MatDateSelectionModel<S, D = ExtractDateTypeFromSelection<
5049
selectionChanged: Observable<DateSelectionModelChange<S>> = this._selectionChanged.asObservable();
5150

5251
protected constructor(
53-
/** Date adapter used when interacting with dates in the model. */
54-
protected readonly adapter: DateAdapter<D>,
5552
/** The current selection. */
5653
readonly selection: S) {
5754
this.selection = selection;
@@ -81,8 +78,8 @@ export abstract class MatDateSelectionModel<S, D = ExtractDateTypeFromSelection<
8178
/** A selection model that contains a single date. */
8279
@Injectable()
8380
export class MatSingleDateSelectionModel<D> extends MatDateSelectionModel<D | null, D> {
84-
constructor(adapter: DateAdapter<D>) {
85-
super(adapter, null);
81+
constructor() {
82+
super(null);
8683
}
8784

8885
/**
@@ -105,8 +102,8 @@ export class MatSingleDateSelectionModel<D> extends MatDateSelectionModel<D | nu
105102
/** A selection model that contains a date range. */
106103
@Injectable()
107104
export class MatRangeDateSelectionModel<D> extends MatDateSelectionModel<DateRange<D>, D> {
108-
constructor(adapter: DateAdapter<D>) {
109-
super(adapter, new DateRange<D>(null, null));
105+
constructor() {
106+
super(new DateRange<D>(null, null));
110107
}
111108

112109
/**
@@ -119,7 +116,7 @@ export class MatRangeDateSelectionModel<D> extends MatDateSelectionModel<DateRan
119116

120117
if (start == null) {
121118
start = date;
122-
} else if (end == null && date && this.adapter.compareDate(date, start) > 0) {
119+
} else if (end == null) {
123120
end = date;
124121
} else {
125122
start = date;
@@ -140,27 +137,27 @@ export class MatRangeDateSelectionModel<D> extends MatDateSelectionModel<DateRan
140137

141138
/** @docs-private */
142139
export function MAT_SINGLE_DATE_SELECTION_MODEL_FACTORY(
143-
parent: MatSingleDateSelectionModel<unknown>, adapter: DateAdapter<unknown>) {
144-
return parent || new MatSingleDateSelectionModel(adapter);
140+
parent: MatSingleDateSelectionModel<unknown>) {
141+
return parent || new MatSingleDateSelectionModel();
145142
}
146143

147144
/** Used to provide a single selection model to a component. */
148145
export const MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER: FactoryProvider = {
149146
provide: MatDateSelectionModel,
150-
deps: [[new Optional(), new SkipSelf(), MatDateSelectionModel], DateAdapter],
147+
deps: [[new Optional(), new SkipSelf(), MatDateSelectionModel]],
151148
useFactory: MAT_SINGLE_DATE_SELECTION_MODEL_FACTORY,
152149
};
153150

154151

155152
/** @docs-private */
156153
export function MAT_RANGE_DATE_SELECTION_MODEL_FACTORY(
157-
parent: MatSingleDateSelectionModel<unknown>, adapter: DateAdapter<unknown>) {
158-
return parent || new MatRangeDateSelectionModel(adapter);
154+
parent: MatSingleDateSelectionModel<unknown>) {
155+
return parent || new MatRangeDateSelectionModel();
159156
}
160157

161158
/** Used to provide a range selection model to a component. */
162159
export const MAT_RANGE_DATE_SELECTION_MODEL_PROVIDER: FactoryProvider = {
163160
provide: MatDateSelectionModel,
164-
deps: [[new Optional(), new SkipSelf(), MatDateSelectionModel], DateAdapter],
161+
deps: [[new Optional(), new SkipSelf(), MatDateSelectionModel]],
165162
useFactory: MAT_RANGE_DATE_SELECTION_MODEL_FACTORY,
166163
};

src/material/datepicker/datepicker-module.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import {MatYearView} from './year-view';
2929
import {MatDateRangeInput} from './date-range-input';
3030
import {MatStartDate, MatEndDate} from './date-range-input-parts';
3131
import {MatDateRangePicker} from './date-range-picker';
32+
import {
33+
MAT_CALENDAR_RANGE_SELECTION_STRATEGY,
34+
DefaultMatCalendarRangeStrategy
35+
} from './calendar-range-selection-strategy';
3236

3337

3438
@NgModule({
@@ -77,6 +81,10 @@ import {MatDateRangePicker} from './date-range-picker';
7781
providers: [
7882
MatDatepickerIntl,
7983
MAT_DATEPICKER_SCROLL_STRATEGY_FACTORY_PROVIDER,
84+
{
85+
provide: MAT_CALENDAR_RANGE_SELECTION_STRATEGY,
86+
useClass: DefaultMatCalendarRangeStrategy
87+
}
8088
],
8189
entryComponents: [
8290
MatDatepickerContent,

src/material/datepicker/month-view.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ import {
3535
} from '@angular/core';
3636
import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core';
3737
import {Directionality} from '@angular/cdk/bidi';
38-
import {MatCalendarBody, MatCalendarCell, MatCalendarCellCssClasses} from './calendar-body';
38+
import {
39+
MatCalendarBody,
40+
MatCalendarCell,
41+
MatCalendarCellCssClasses,
42+
MatCalendarUserEvent,
43+
} from './calendar-body';
3944
import {createMissingDateImplError} from './datepicker-errors';
4045
import {Subscription} from 'rxjs';
4146
import {startWith} from 'rxjs/operators';
@@ -121,7 +126,8 @@ export class MatMonthView<D> implements AfterContentInit, OnDestroy {
121126
@Output() readonly selectedChange: EventEmitter<D | null> = new EventEmitter<D | null>();
122127

123128
/** Emits when any date is selected. */
124-
@Output() readonly _userSelection: EventEmitter<void> = new EventEmitter<void>();
129+
@Output() readonly _userSelection: EventEmitter<MatCalendarUserEvent<D | null>> =
130+
new EventEmitter<MatCalendarUserEvent<D | null>>();
125131

126132
/** Emits when any date is activated. */
127133
@Output() readonly activeDateChange: EventEmitter<D> = new EventEmitter<D>();
@@ -181,7 +187,9 @@ export class MatMonthView<D> implements AfterContentInit, OnDestroy {
181187
}
182188

183189
/** Handles when a new date is selected. */
184-
_dateSelected(date: number) {
190+
_dateSelected(event: MatCalendarUserEvent<number>) {
191+
const date = event.value;
192+
let selectedDate: D | null = null;
185193
let rangeStartDate: number | null;
186194
let rangeEndDate: number | null;
187195

@@ -195,12 +203,11 @@ export class MatMonthView<D> implements AfterContentInit, OnDestroy {
195203
if (rangeStartDate !== date || rangeEndDate !== date) {
196204
const selectedYear = this._dateAdapter.getYear(this.activeDate);
197205
const selectedMonth = this._dateAdapter.getMonth(this.activeDate);
198-
const selectedDate = this._dateAdapter.createDate(selectedYear, selectedMonth, date);
199-
206+
selectedDate = this._dateAdapter.createDate(selectedYear, selectedMonth, date);
200207
this.selectedChange.emit(selectedDate);
201208
}
202209

203-
this._userSelection.emit();
210+
this._userSelection.emit({value: selectedDate, event: event.event});
204211
}
205212

206213
/** Handles keydown events on the calendar body when calendar is in month view. */
@@ -247,8 +254,7 @@ export class MatMonthView<D> implements AfterContentInit, OnDestroy {
247254
case ENTER:
248255
case SPACE:
249256
if (!this.dateFilter || this.dateFilter(this._activeDate)) {
250-
this._dateSelected(this._dateAdapter.getDate(this._activeDate));
251-
this._userSelection.emit();
257+
this._dateSelected({value: this._dateAdapter.getDate(this._activeDate), event});
252258
// Prevent unexpected default actions such as form submission.
253259
event.preventDefault();
254260
}
@@ -259,7 +265,7 @@ export class MatMonthView<D> implements AfterContentInit, OnDestroy {
259265
// rest of the range logic, because focus may have moved outside the calendar body.
260266
if (this._matCalendarBody._previewEnd > -1) {
261267
this.selectedChange.emit(null);
262-
this._userSelection.emit();
268+
this._userSelection.emit({value: null, event});
263269
event.preventDefault();
264270
event.stopPropagation(); // Prevents the overlay from closing.
265271
}

src/material/datepicker/multi-year-view.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333
} from '@angular/core';
3434
import {DateAdapter} from '@angular/material/core';
3535
import {Directionality} from '@angular/cdk/bidi';
36-
import {MatCalendarBody, MatCalendarCell} from './calendar-body';
36+
import {MatCalendarBody, MatCalendarCell, MatCalendarUserEvent} from './calendar-body';
3737
import {createMissingDateImplError} from './datepicker-errors';
3838
import {Subscription} from 'rxjs';
3939
import {startWith} from 'rxjs/operators';
@@ -174,7 +174,8 @@ export class MatMultiYearView<D> implements AfterContentInit, OnDestroy {
174174
}
175175

176176
/** Handles when a new year is selected. */
177-
_yearSelected(year: number) {
177+
_yearSelected(event: MatCalendarUserEvent<number>) {
178+
const year = event.value;
178179
this.yearSelected.emit(this._dateAdapter.createDate(year, 0, 1));
179180
let month = this._dateAdapter.getMonth(this.activeDate);
180181
let daysInMonth =
@@ -222,7 +223,7 @@ export class MatMultiYearView<D> implements AfterContentInit, OnDestroy {
222223
break;
223224
case ENTER:
224225
case SPACE:
225-
this._yearSelected(this._dateAdapter.getYear(this._activeDate));
226+
this._yearSelected({value: this._dateAdapter.getYear(this._activeDate), event});
226227
break;
227228
default:
228229
// Don't prevent default or focus active cell on keys that we don't explicitly handle.

0 commit comments

Comments
 (0)