Skip to content

Commit ef54582

Browse files
committed
feat(datepicker): allow for date range selection logic to be customized (#18980)
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 8daa7eb commit ef54582

17 files changed

+222
-68
lines changed

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import {MatIconModule} from '@angular/material/icon';
1818
import {MatInputModule} from '@angular/material/input';
1919
import {MatSelectModule} from '@angular/material/select';
2020
import {RouterModule} from '@angular/router';
21-
import {CustomHeader, CustomHeaderNgContent, DatepickerDemo} from './datepicker-demo';
21+
import {
22+
CustomHeader,
23+
CustomHeaderNgContent,
24+
DatepickerDemo,
25+
CustomRangeStrategy,
26+
} from './datepicker-demo';
2227

2328
@NgModule({
2429
imports: [
@@ -35,7 +40,7 @@ import {CustomHeader, CustomHeaderNgContent, DatepickerDemo} from './datepicker-
3540
ReactiveFormsModule,
3641
RouterModule.forChild([{path: '', component: DatepickerDemo}]),
3742
],
38-
declarations: [CustomHeader, CustomHeaderNgContent, DatepickerDemo],
43+
declarations: [CustomHeader, CustomHeaderNgContent, DatepickerDemo, CustomRangeStrategy],
3944
})
4045
export class DatepickerDemoModule {
4146
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,17 @@ <h2>Range picker</h2>
244244
</mat-form-field>
245245
<div>{{range3.value | json}}</div>
246246
</div>
247+
248+
249+
<h2>Range picker with custom selection strategy</h2>
250+
<div class="demo-range-group">
251+
<mat-form-field>
252+
<mat-label>Enter a date range</mat-label>
253+
<mat-date-range-input [rangePicker]="range4Picker">
254+
<input matStartDate placeholder="Start date"/>
255+
<input matEndDate placeholder="End date"/>
256+
</mat-date-range-input>
257+
<mat-datepicker-toggle [for]="range4Picker" matSuffix></mat-datepicker-toggle>
258+
<mat-date-range-picker customRangeStrategy #range4Picker></mat-date-range-picker>
259+
</mat-form-field>
260+
</div>

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

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,19 @@ import {
1414
OnDestroy,
1515
Optional,
1616
ViewChild,
17-
ViewEncapsulation
17+
ViewEncapsulation,
18+
Directive,
19+
Injectable
1820
} from '@angular/core';
1921
import {FormControl, FormGroup} from '@angular/forms';
2022
import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats, ThemePalette} from '@angular/material/core';
2123
import {
2224
MatCalendar,
2325
MatCalendarHeader,
24-
MatDatepickerInputEvent
26+
MatDatepickerInputEvent,
27+
MAT_CALENDAR_RANGE_SELECTION_STRATEGY,
28+
MatCalendarRangeSelectionStrategy,
29+
DateRange
2530
} from '@angular/material/datepicker';
2631
import {Subject} from 'rxjs';
2732
import {takeUntil} from 'rxjs/operators';
@@ -79,6 +84,37 @@ export class DatepickerDemo {
7984
customHeaderNgContent = CustomHeaderNgContent;
8085
}
8186

87+
/** Range selection strategy that preserves the current range. */
88+
@Injectable()
89+
export class PreserveRangeStrategy<D> implements MatCalendarRangeSelectionStrategy<D> {
90+
constructor(private _dateAdapter: DateAdapter<D>) {}
91+
92+
selectionFinished(date: D, currentRange: DateRange<D>) {
93+
let {start, end} = currentRange;
94+
95+
if (start == null) {
96+
start = date;
97+
} else if (end == null) {
98+
end = date;
99+
} else if (this._dateAdapter.compareDate(start, date) > 0) {
100+
start = date;
101+
} else {
102+
end = date;
103+
}
104+
105+
return new DateRange<D>(start, end);
106+
}
107+
}
108+
109+
@Directive({
110+
selector: '[customRangeStrategy]',
111+
providers: [{
112+
provide: MAT_CALENDAR_RANGE_SELECTION_STRATEGY,
113+
useClass: PreserveRangeStrategy
114+
}]
115+
})
116+
export class CustomRangeStrategy {}
117+
82118
// Custom header component for datepicker
83119
@Component({
84120
selector: 'custom-header',

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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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+
// TODO(crisbeto): this needs to be expanded to allow for the preview range to be customized.
14+
15+
/** Injection token used to customize the date range selection behavior. */
16+
export const MAT_CALENDAR_RANGE_SELECTION_STRATEGY =
17+
new InjectionToken<MatCalendarRangeSelectionStrategy<any>>(
18+
'MAT_CALENDAR_RANGE_SELECTION_STRATEGY');
19+
20+
/** Object that can be provided in order to customize the date range selection behavior. */
21+
export interface MatCalendarRangeSelectionStrategy<D> {
22+
/** Called when the user has finished selecting a value. */
23+
selectionFinished(date: D | null, currentRange: DateRange<D>, event: Event): DateRange<D>;
24+
}
25+
26+
/** Provides the default date range selection behavior. */
27+
@Injectable()
28+
export class DefaultMatCalendarRangeStrategy<D> implements MatCalendarRangeSelectionStrategy<D> {
29+
constructor(private _dateAdapter: DateAdapter<D>) {}
30+
31+
selectionFinished(date: D, currentRange: DateRange<D>) {
32+
let {start, end} = currentRange;
33+
34+
if (start == null) {
35+
start = date;
36+
} else if (end == null && date && this._dateAdapter.compareDate(date, start) > 0) {
37+
end = date;
38+
} else {
39+
start = date;
40+
end = null;
41+
}
42+
43+
return new DateRange<D>(start, end);
44+
}
45+
}

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
};

0 commit comments

Comments
 (0)