Skip to content

Commit 83e6e16

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 83e6e16

18 files changed

+251
-95
lines changed

goldens/ts-circular-deps.json

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
[
2-
[
3-
"src/cdk-experimental/dialog/dialog-config.ts",
4-
"src/cdk-experimental/dialog/dialog-container.ts"
5-
],
62
[
73
"src/cdk-experimental/popover-edit/edit-event-dispatcher.ts",
84
"src/cdk-experimental/popover-edit/edit-ref.ts"
95
],
6+
[
7+
"src/cdk/scrolling/scroll-dispatcher.ts",
8+
"src/cdk/scrolling/scrollable.ts"
9+
],
10+
[
11+
"src/cdk/scrolling/virtual-scroll-viewport.ts",
12+
"src/cdk/scrolling/virtual-for-of.ts"
13+
],
14+
[
15+
"src/cdk/scrolling/virtual-scroll-strategy.ts",
16+
"src/cdk/scrolling/virtual-scroll-viewport.ts"
17+
],
18+
[
19+
"src/cdk/overlay/keyboard/overlay-keyboard-dispatcher.ts",
20+
"src/cdk/overlay/overlay-ref.ts"
21+
],
22+
[
23+
"src/cdk-experimental/dialog/dialog-config.ts",
24+
"src/cdk-experimental/dialog/dialog-container.ts"
25+
],
1026
[
1127
"src/cdk/drag-drop/drag-ref.ts",
1228
"src/cdk/drag-drop/drop-list-ref.ts"
@@ -25,29 +41,13 @@
2541
"src/cdk/drag-drop/directives/drag.ts"
2642
],
2743
[
28-
"src/cdk/overlay/keyboard/overlay-keyboard-dispatcher.ts",
29-
"src/cdk/overlay/overlay-ref.ts"
44+
"src/cdk/testing/private/e2e/actions.ts",
45+
"src/cdk/testing/private/e2e/query.ts"
3046
],
3147
[
3248
"src/cdk/schematics/testing/index.ts",
3349
"src/cdk/schematics/testing/test-case-setup.ts"
3450
],
35-
[
36-
"src/cdk/scrolling/scroll-dispatcher.ts",
37-
"src/cdk/scrolling/scrollable.ts"
38-
],
39-
[
40-
"src/cdk/scrolling/virtual-scroll-viewport.ts",
41-
"src/cdk/scrolling/virtual-for-of.ts"
42-
],
43-
[
44-
"src/cdk/scrolling/virtual-scroll-strategy.ts",
45-
"src/cdk/scrolling/virtual-scroll-viewport.ts"
46-
],
47-
[
48-
"src/cdk/testing/private/e2e/actions.ts",
49-
"src/cdk/testing/private/e2e/query.ts"
50-
],
5151
[
5252
"src/material/core/ripple/ripple-ref.ts",
5353
"src/material/core/ripple/ripple-renderer.ts"
@@ -56,6 +56,10 @@
5656
"src/material/datepicker/datepicker.ts",
5757
"src/material/datepicker/datepicker-input.ts"
5858
],
59+
[
60+
"src/material/datepicker/date-range-input.ts",
61+
"src/material/datepicker/date-range-picker.ts"
62+
],
5963
[
6064
"src/material/grid-list/grid-list.ts",
6165
"src/material/grid-list/tile-styler.ts"

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

0 commit comments

Comments
 (0)