Skip to content

Commit 4439587

Browse files
committed
feat(datepicker): polish up date range selector (#18531)
Makes a bunch of changes to polish up the behavior of the date range picker. - Makes the range selection logic a bit smarter about when to pick start/end so that it's more intuitive. - Fixes hovering over disabled cells triggering the range selection styles. - Fixes a "changed after checked" error when selecting a range using the keyboard. - Implements validation that the start isn't after the end, and that the end isn't before the start. - Adds the missing ARIA attributes. - Fixes being able to select the placeholders of a disabled range input. - Adds the ability to disable the entire range input. - Fixes the inputs not greying out their values when they're disabled. - Makes the range input a bit smarter about which input to focus on click.
1 parent ad08470 commit 4439587

File tree

12 files changed

+149
-51
lines changed

12 files changed

+149
-51
lines changed

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

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,38 +175,65 @@ <h2>Range picker</h2>
175175
<div class="demo-range-group">
176176
<mat-form-field>
177177
<mat-label>Enter a date range</mat-label>
178-
<mat-date-range-input [formGroup]="range1" [rangePicker]="range1Picker">
178+
<mat-date-range-input
179+
[formGroup]="range1"
180+
[rangePicker]="range1Picker"
181+
[min]="minDate"
182+
[max]="maxDate"
183+
[disabled]="inputDisabled"
184+
[dateFilter]="filterOdd ? dateFilter : undefined">
179185
<input matStartDate formControlName="start" placeholder="Start date"/>
180186
<input matEndDate formControlName="end" placeholder="End date"/>
181187
</mat-date-range-input>
182188
<mat-datepicker-toggle [for]="range1Picker" matSuffix></mat-datepicker-toggle>
183-
<mat-date-range-picker #range1Picker></mat-date-range-picker>
189+
<mat-date-range-picker
190+
[touchUi]="touch"
191+
[disabled]="datepickerDisabled"
192+
#range1Picker></mat-date-range-picker>
184193
</mat-form-field>
185194
<div>{{range1.value | json}}</div>
186195
</div>
187196

188197
<div class="demo-range-group">
189198
<mat-form-field appearance="fill">
190199
<mat-label>Enter a date range</mat-label>
191-
<mat-date-range-input [formGroup]="range2" [rangePicker]="range2Picker">
200+
<mat-date-range-input
201+
[formGroup]="range2"
202+
[rangePicker]="range2Picker"
203+
[min]="minDate"
204+
[max]="maxDate"
205+
[disabled]="inputDisabled"
206+
[dateFilter]="filterOdd ? dateFilter : undefined">
192207
<input matStartDate formControlName="start" placeholder="Start date"/>
193208
<input matEndDate formControlName="end" placeholder="End date"/>
194209
</mat-date-range-input>
195210
<mat-datepicker-toggle [for]="range2Picker" matSuffix></mat-datepicker-toggle>
196-
<mat-date-range-picker #range2Picker></mat-date-range-picker>
211+
<mat-date-range-picker
212+
[touchUi]="touch"
213+
[disabled]="datepickerDisabled"
214+
#range2Picker></mat-date-range-picker>
197215
</mat-form-field>
198216
<div>{{range2.value | json}}</div>
199217
</div>
200218

201219
<div class="demo-range-group">
202220
<mat-form-field appearance="outline">
203221
<mat-label>Enter a date range</mat-label>
204-
<mat-date-range-input [formGroup]="range3" [rangePicker]="range3Picker">
222+
<mat-date-range-input
223+
[formGroup]="range3"
224+
[rangePicker]="range3Picker"
225+
[min]="minDate"
226+
[max]="maxDate"
227+
[disabled]="inputDisabled"
228+
[dateFilter]="filterOdd ? dateFilter : undefined">
205229
<input matStartDate formControlName="start" placeholder="Start date"/>
206230
<input matEndDate formControlName="end" placeholder="End date"/>
207231
</mat-date-range-input>
208232
<mat-datepicker-toggle [for]="range3Picker" matSuffix></mat-datepicker-toggle>
209-
<mat-date-range-picker #range3Picker></mat-date-range-picker>
233+
<mat-date-range-picker
234+
[touchUi]="touch"
235+
[disabled]="datepickerDisabled"
236+
#range3Picker></mat-date-range-picker>
210237
</mat-form-field>
211238
<div>{{range3.value | json}}</div>
212239
</div>

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

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -147,18 +147,11 @@ export class MatRangeDateSelectionModel<D> extends MatDateSelectionModel<DateRan
147147

148148
if (start == null) {
149149
start = date;
150-
} else if (end == null) {
150+
} else if (end == null && date && this.adapter.compareDate(date, start) > 0) {
151151
end = date;
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-
}
152+
} else {
153+
start = date;
154+
end = null;
162155
}
163156

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

src/material/datepicker/_datepicker-theme.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ $mat-calendar-weekday-table-font-size: 11px !default;
137137
color: mat-color(map-get($theme, warn), text);
138138
}
139139
}
140+
141+
.mat-date-range-input-inner:disabled {
142+
color: mat-color($foreground, disabled-text);
143+
}
140144
}
141145

142146
@mixin mat-datepicker-typography($config) {

src/material/datepicker/calendar-body.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
101101
/** Width of an individual cell. */
102102
_cellWidth: string;
103103

104+
/**
105+
* Value that the user is either currently hovering over or is focusing
106+
* using the keyboard. Only applies when selecting the end of a date range.
107+
*/
104108
_hoveredValue: number;
105109

106110
constructor(
@@ -201,15 +205,15 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
201205
private _enterHandler = (event: Event) => {
202206
// We only need to hit the zone when we're selecting a range, we
203207
// 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) {
208+
if (!event.target || !this.startValue || this.endValue || !this._isRange()) {
205209
return;
206210
}
207211

208212
const cell = this._getCellFromElement(event.target as HTMLElement);
209213

210214
if (cell) {
211215
this._ngZone.run(() => {
212-
this._hoveredValue = cell.compareValue;
216+
this._hoveredValue = cell.enabled ? cell.compareValue : -1;
213217
this._changeDetectorRef.markForCheck();
214218
});
215219
}
@@ -221,14 +225,19 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
221225
*/
222226
private _leaveHandler = (event: Event) => {
223227
// We only need to hit the zone when we're selecting a range.
224-
if (this._isRange() && this._hoveredValue !== -1) {
228+
if (this._hoveredValue !== -1 && this._isRange()) {
225229
// Only reset the hovered value when leaving cells. This looks better, because
226230
// we have a gap between the cells and the rows and we don't want to remove the
227231
// range just for it to show up again when the user moves a few pixels to the side.
228232
if (event.target && isTableCell(event.target as HTMLElement)) {
229233
this._ngZone.run(() => {
230234
this._hoveredValue = -1;
231-
this._changeDetectorRef.markForCheck();
235+
236+
// Note that here we need to use `detectChanges`, rather than `markForCheck`, because
237+
// the way `_focusActiveCell` is set up at the moment makes it fire at the wrong time
238+
// when navigating one month back using the keyboard which will cause this handler
239+
// to throw a "changed after checked" error when updating the hover state.
240+
this._changeDetectorRef.detectChanges();
232241
});
233242
}
234243
}

src/material/datepicker/date-range-input-parts.ts

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
NgControl,
2626
ValidatorFn,
2727
Validators,
28+
AbstractControl,
29+
ValidationErrors,
2830
} from '@angular/forms';
2931
import {
3032
CanUpdateErrorState,
@@ -45,6 +47,7 @@ export interface MatDateRangeInputParent<D> {
4547
min: D | null;
4648
max: D | null;
4749
dateFilter: DateFilterFn<D>;
50+
_groupDisabled: boolean;
4851
_ariaDescribedBy: string | null;
4952
_ariaLabelledBy: string | null;
5053
_handleChildValueChange: () => void;
@@ -145,6 +148,16 @@ abstract class MatDateRangeInputPartBase<D>
145148
protected _getDateFilter() {
146149
return this._rangeInput.dateFilter;
147150
}
151+
152+
protected _outsideValueChanged = () => {
153+
// Whenever the value changes outside the input we need to revalidate, because
154+
// the validation state of each of the inputs depends on the other one.
155+
this._validatorOnChange();
156+
}
157+
158+
protected _parentDisabled() {
159+
return this._rangeInput._groupDisabled;
160+
}
148161
}
149162

150163
const _MatDateRangeInputBase:
@@ -163,23 +176,29 @@ const _MatDateRangeInputBase:
163176
'(keydown)': '_onKeydown($event)',
164177
'[attr.aria-labelledby]': '_rangeInput._ariaLabelledBy',
165178
'[attr.aria-describedby]': '_rangeInput._ariaDescribedBy',
179+
'[attr.aria-haspopup]': '_rangeInput.rangePicker ? "dialog" : null',
180+
'[attr.aria-owns]': '(_rangeInput.rangePicker?.opened && _rangeInput.rangePicker.id) || null',
181+
'[attr.min]': '_getMinDate() ? _dateAdapter.toIso8601(_getMinDate()) : null',
182+
'[attr.max]': '_getMaxDate() ? _dateAdapter.toIso8601(_getMaxDate()) : null',
166183
'(blur)': '_onBlur()',
167184
'type': 'text',
168-
169-
// TODO(crisbeto): to be added once the datepicker is implemented
170-
// '[attr.aria-haspopup]': '_datepicker ? "dialog" : null',
171-
// '[attr.aria-owns]': '(_datepicker?.opened && _datepicker.id) || null',
172-
// '[attr.min]': 'min ? _dateAdapter.toIso8601(min) : null',
173-
// '[attr.max]': 'max ? _dateAdapter.toIso8601(max) : null',
174185
},
175186
providers: [
176187
{provide: NG_VALUE_ACCESSOR, useExisting: MatStartDate, multi: true},
177188
{provide: NG_VALIDATORS, useExisting: MatStartDate, multi: true}
178189
]
179190
})
180191
export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpdateErrorState {
181-
// TODO(crisbeto): start-range-specific validators should go here.
182-
protected _validator = Validators.compose(super._getValidators());
192+
/** Validator that checks whether the start date isn't after the end date. */
193+
private _startValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
194+
const start = this._getValidDateOrNull(this._dateAdapter.deserialize(control.value));
195+
const end = this._model ? this._model.selection.end : null;
196+
return (!start || !end ||
197+
this._dateAdapter.compareDate(start, end) <= 0) ?
198+
null : {'matStartDateInvalid': {'end': end, 'actual': start}};
199+
}
200+
201+
protected _validator = Validators.compose([...super._getValidators(), this._startValidator]);
183202

184203
protected _getValueFromModel(modelValue: DateRange<D>) {
185204
return modelValue.start;
@@ -220,23 +239,29 @@ export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpd
220239
'(keydown)': '_onKeydown($event)',
221240
'[attr.aria-labelledby]': '_rangeInput._ariaLabelledBy',
222241
'[attr.aria-describedby]': '_rangeInput._ariaDescribedBy',
242+
'[attr.aria-haspopup]': '_rangeInput.rangePicker ? "dialog" : null',
243+
'[attr.aria-owns]': '(_rangeInput.rangePicker?.opened && _rangeInput.rangePicker.id) || null',
244+
'[attr.min]': '_getMinDate() ? _dateAdapter.toIso8601(_getMinDate()) : null',
245+
'[attr.max]': '_getMaxDate() ? _dateAdapter.toIso8601(_getMaxDate()) : null',
223246
'(blur)': '_onBlur()',
224247
'type': 'text',
225-
226-
// TODO(crisbeto): to be added once the datepicker is implemented
227-
// '[attr.aria-haspopup]': '_datepicker ? "dialog" : null',
228-
// '[attr.aria-owns]': '(_datepicker?.opened && _datepicker.id) || null',
229-
// '[attr.min]': 'min ? _dateAdapter.toIso8601(min) : null',
230-
// '[attr.max]': 'max ? _dateAdapter.toIso8601(max) : null',
231248
},
232249
providers: [
233250
{provide: NG_VALUE_ACCESSOR, useExisting: MatEndDate, multi: true},
234251
{provide: NG_VALIDATORS, useExisting: MatEndDate, multi: true}
235252
]
236253
})
237254
export class MatEndDate<D> extends _MatDateRangeInputBase<D> implements CanUpdateErrorState {
238-
// TODO(crisbeto): end-range-specific validators should go here.
239-
protected _validator = Validators.compose(super._getValidators());
255+
/** Validator that checks whether the end date isn't before the start date. */
256+
private _endValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
257+
const end = this._getValidDateOrNull(this._dateAdapter.deserialize(control.value));
258+
const start = this._model ? this._model.selection.start : null;
259+
return (!end || !start ||
260+
this._dateAdapter.compareDate(end, start) >= 0) ?
261+
null : {'matEndDateInvalid': {'start': start, 'actual': end}};
262+
}
263+
264+
protected _validator = Validators.compose([...super._getValidators(), this._endValidator]);
240265

241266
protected _getValueFromModel(modelValue: DateRange<D>) {
242267
return modelValue.end;

src/material/datepicker/date-range-input.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ $mat-date-range-input-placeholder-transition:
2424
transition: $mat-date-range-input-placeholder-transition;
2525

2626
.mat-form-field-hide-placeholder & {
27+
// Disable text selection, because the user can click
28+
// through the main label when the input is disabled.
29+
@include user-select(none);
2730
color: transparent;
2831
transition: none;
2932
}
@@ -57,6 +60,10 @@ $mat-date-range-input-placeholder-transition:
5760
.mat-form-field-hide-placeholder &,
5861
.mat-date-range-input-hide-placeholders & {
5962
@include input-placeholder {
63+
// Disable text selection, because the user can click
64+
// through the main label when the input is disabled.
65+
@include user-select(none);
66+
6067
// Needs to be !important, because the placeholder will end up inheriting the
6168
// input color in IE, if the consumer overrides it with a higher specificity.
6269
color: transparent !important;

src/material/datepicker/date-range-input.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,21 @@ export class MatDateRangeInput<D> implements MatFormFieldControl<DateRange<D>>,
140140
private _max: D | null;
141141

142142
/** Whether the input is disabled. */
143+
@Input()
143144
get disabled(): boolean {
144-
if (this._startInput && this._endInput) {
145-
return this._startInput.disabled && this._endInput.disabled;
146-
}
145+
return (this._startInput && this._endInput) ?
146+
(this._startInput.disabled && this._endInput.disabled) :
147+
this._groupDisabled;
148+
}
149+
set disabled(value: boolean) {
150+
const newValue = coerceBooleanProperty(value);
147151

148-
return false;
152+
if (newValue !== this._groupDisabled) {
153+
this._groupDisabled = newValue;
154+
this._disabledChange.next(this.disabled);
155+
}
149156
}
157+
_groupDisabled = false;
150158

151159
/** Whether the input is in an error state. */
152160
get errorState(): boolean {
@@ -218,9 +226,12 @@ export class MatDateRangeInput<D> implements MatFormFieldControl<DateRange<D>>,
218226
* @docs-private
219227
*/
220228
onContainerClick(): void {
221-
if (!this.focused) {
222-
// TODO(crisbeto): maybe this should go to end input if start has a value?
223-
this._startInput.focus();
229+
if (!this.focused && !this.disabled) {
230+
if (!this._model || !this._model.selection.start) {
231+
this._startInput.focus();
232+
} else {
233+
this._endInput.focus();
234+
}
224235
}
225236
}
226237

@@ -317,4 +328,5 @@ export class MatDateRangeInput<D> implements MatFormFieldControl<DateRange<D>>,
317328
}
318329

319330
static ngAcceptInputType_required: BooleanInput;
331+
static ngAcceptInputType_disabled: BooleanInput;
320332
}

src/material/datepicker/datepicker-input-base.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
8181

8282
/** Whether the datepicker-input is disabled. */
8383
@Input()
84-
get disabled(): boolean { return !!this._disabled; }
84+
get disabled(): boolean { return !!this._disabled || this._parentDisabled(); }
8585
set disabled(value: boolean) {
8686
const newValue = coerceBooleanProperty(value);
8787
const element = this._elementRef.nativeElement;
@@ -192,6 +192,10 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
192192
this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
193193
this.dateChange.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
194194
this._formatValue(value);
195+
196+
if (this._outsideValueChanged) {
197+
this._outsideValueChanged();
198+
}
195199
}
196200
});
197201
}
@@ -205,9 +209,15 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
205209
/** Converts a value from the model into a native value for the input. */
206210
protected abstract _getValueFromModel(modelValue: S): D | null;
207211

208-
/** The combined form control validator for this input. */
212+
/** Combined form control validator for this input. */
209213
protected abstract _validator: ValidatorFn | null;
210214

215+
/**
216+
* Callback that'll be invoked when the selection model is changed
217+
* from somewhere that's not the current datepicker input.
218+
*/
219+
protected abstract _outsideValueChanged?: () => void;
220+
211221
/** Whether the last value set on the input was valid. */
212222
protected _lastValueValid = false;
213223

@@ -330,6 +340,14 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
330340
}
331341
}
332342

343+
/**
344+
* Checks whether a parent control is disabled. This is in place so that it can be overridden
345+
* by inputs extending this one which can be placed inside of a group that can be disabled.
346+
*/
347+
protected _parentDisabled() {
348+
return false;
349+
}
350+
333351
// Accept `any` to avoid conflicts with other directives on `<input>` that
334352
// may accept different types.
335353
static ngAcceptInputType_value: any;

src/material/datepicker/datepicker-input.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ export class MatDatepickerInput<D> extends MatDatepickerInputBase<D | null, D>
176176
return this._dateFilter;
177177
}
178178

179+
// Unnecessary when selecting a single date.
180+
protected _outsideValueChanged: undefined;
181+
179182
// Accept `any` to avoid conflicts with other directives on `<input>` that
180183
// may accept different types.
181184
static ngAcceptInputType_value: any;

0 commit comments

Comments
 (0)