Skip to content

feat(datepicker): polish up date range selector #18531

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions src/dev-app/datepicker/datepicker-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -175,38 +175,65 @@ <h2>Range picker</h2>
<div class="demo-range-group">
<mat-form-field>
<mat-label>Enter a date range</mat-label>
<mat-date-range-input [formGroup]="range1" [rangePicker]="range1Picker">
<mat-date-range-input
[formGroup]="range1"
[rangePicker]="range1Picker"
[min]="minDate"
[max]="maxDate"
[disabled]="inputDisabled"
[dateFilter]="filterOdd ? dateFilter : undefined">
<input matStartDate formControlName="start" placeholder="Start date"/>
<input matEndDate formControlName="end" placeholder="End date"/>
</mat-date-range-input>
<mat-datepicker-toggle [for]="range1Picker" matSuffix></mat-datepicker-toggle>
<mat-date-range-picker #range1Picker></mat-date-range-picker>
<mat-date-range-picker
[touchUi]="touch"
[disabled]="datepickerDisabled"
#range1Picker></mat-date-range-picker>
</mat-form-field>
<div>{{range1.value | json}}</div>
</div>

<div class="demo-range-group">
<mat-form-field appearance="fill">
<mat-label>Enter a date range</mat-label>
<mat-date-range-input [formGroup]="range2" [rangePicker]="range2Picker">
<mat-date-range-input
[formGroup]="range2"
[rangePicker]="range2Picker"
[min]="minDate"
[max]="maxDate"
[disabled]="inputDisabled"
[dateFilter]="filterOdd ? dateFilter : undefined">
<input matStartDate formControlName="start" placeholder="Start date"/>
<input matEndDate formControlName="end" placeholder="End date"/>
</mat-date-range-input>
<mat-datepicker-toggle [for]="range2Picker" matSuffix></mat-datepicker-toggle>
<mat-date-range-picker #range2Picker></mat-date-range-picker>
<mat-date-range-picker
[touchUi]="touch"
[disabled]="datepickerDisabled"
#range2Picker></mat-date-range-picker>
</mat-form-field>
<div>{{range2.value | json}}</div>
</div>

<div class="demo-range-group">
<mat-form-field appearance="outline">
<mat-label>Enter a date range</mat-label>
<mat-date-range-input [formGroup]="range3" [rangePicker]="range3Picker">
<mat-date-range-input
[formGroup]="range3"
[rangePicker]="range3Picker"
[min]="minDate"
[max]="maxDate"
[disabled]="inputDisabled"
[dateFilter]="filterOdd ? dateFilter : undefined">
<input matStartDate formControlName="start" placeholder="Start date"/>
<input matEndDate formControlName="end" placeholder="End date"/>
</mat-date-range-input>
<mat-datepicker-toggle [for]="range3Picker" matSuffix></mat-datepicker-toggle>
<mat-date-range-picker #range3Picker></mat-date-range-picker>
<mat-date-range-picker
[touchUi]="touch"
[disabled]="datepickerDisabled"
#range3Picker></mat-date-range-picker>
</mat-form-field>
<div>{{range3.value | json}}</div>
</div>
15 changes: 4 additions & 11 deletions src/material/core/datetime/date-selection-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,18 +147,11 @@ export class MatRangeDateSelectionModel<D> extends MatDateSelectionModel<DateRan

if (start == null) {
start = date;
} else if (end == null) {
} else if (end == null && date && this.adapter.compareDate(date, start) > 0) {
end = date;
} else if (date) {
if (this.adapter.compareDate(date, start) <= 0) {
start = date;

if (end) {
end = null;
}
} else {
end = date;
}
} else {
start = date;
end = null;
}

super.updateSelection(new DateRange<D>(start, end), this);
Expand Down
4 changes: 4 additions & 0 deletions src/material/datepicker/_datepicker-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ $mat-calendar-weekday-table-font-size: 11px !default;
color: mat-color(map-get($theme, warn), text);
}
}

.mat-date-range-input-inner:disabled {
color: mat-color($foreground, disabled-text);
}
}

@mixin mat-datepicker-typography($config) {
Expand Down
17 changes: 13 additions & 4 deletions src/material/datepicker/calendar-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
/** Width of an individual cell. */
_cellWidth: string;

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

constructor(
Expand Down Expand Up @@ -201,15 +205,15 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
private _enterHandler = (event: Event) => {
// We only need to hit the zone when we're selecting a range, we
// have a start value without an end value and we've hovered over a date cell.
if (!event.target || !this._isRange() || !this.startValue || this.endValue) {
if (!event.target || !this.startValue || this.endValue || !this._isRange()) {
return;
}

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

if (cell) {
this._ngZone.run(() => {
this._hoveredValue = cell.compareValue;
this._hoveredValue = cell.enabled ? cell.compareValue : -1;
this._changeDetectorRef.markForCheck();
});
}
Expand All @@ -221,14 +225,19 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
*/
private _leaveHandler = (event: Event) => {
// We only need to hit the zone when we're selecting a range.
if (this._isRange() && this._hoveredValue !== -1) {
if (this._hoveredValue !== -1 && this._isRange()) {
// Only reset the hovered value when leaving cells. This looks better, because
// we have a gap between the cells and the rows and we don't want to remove the
// range just for it to show up again when the user moves a few pixels to the side.
if (event.target && isTableCell(event.target as HTMLElement)) {
this._ngZone.run(() => {
this._hoveredValue = -1;
this._changeDetectorRef.markForCheck();

// Note that here we need to use `detectChanges`, rather than `markForCheck`, because
// the way `_focusActiveCell` is set up at the moment makes it fire at the wrong time
// when navigating one month back using the keyboard which will cause this handler
// to throw a "changed after checked" error when updating the hover state.
this._changeDetectorRef.detectChanges();
});
}
}
Expand Down
57 changes: 41 additions & 16 deletions src/material/datepicker/date-range-input-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
NgControl,
ValidatorFn,
Validators,
AbstractControl,
ValidationErrors,
} from '@angular/forms';
import {
CanUpdateErrorState,
Expand All @@ -45,6 +47,7 @@ export interface MatDateRangeInputParent<D> {
min: D | null;
max: D | null;
dateFilter: DateFilterFn<D>;
_groupDisabled: boolean;
_ariaDescribedBy: string | null;
_ariaLabelledBy: string | null;
_handleChildValueChange: () => void;
Expand Down Expand Up @@ -145,6 +148,16 @@ abstract class MatDateRangeInputPartBase<D>
protected _getDateFilter() {
return this._rangeInput.dateFilter;
}

protected _outsideValueChanged = () => {
// Whenever the value changes outside the input we need to revalidate, because
// the validation state of each of the inputs depends on the other one.
this._validatorOnChange();
}

protected _parentDisabled() {
return this._rangeInput._groupDisabled;
}
}

const _MatDateRangeInputBase:
Expand All @@ -163,23 +176,29 @@ const _MatDateRangeInputBase:
'(keydown)': '_onKeydown($event)',
'[attr.aria-labelledby]': '_rangeInput._ariaLabelledBy',
'[attr.aria-describedby]': '_rangeInput._ariaDescribedBy',
'[attr.aria-haspopup]': '_rangeInput.rangePicker ? "dialog" : null',
'[attr.aria-owns]': '(_rangeInput.rangePicker?.opened && _rangeInput.rangePicker.id) || null',
'[attr.min]': '_getMinDate() ? _dateAdapter.toIso8601(_getMinDate()) : null',
'[attr.max]': '_getMaxDate() ? _dateAdapter.toIso8601(_getMaxDate()) : null',
'(blur)': '_onBlur()',
'type': 'text',

// TODO(crisbeto): to be added once the datepicker is implemented
// '[attr.aria-haspopup]': '_datepicker ? "dialog" : null',
// '[attr.aria-owns]': '(_datepicker?.opened && _datepicker.id) || null',
// '[attr.min]': 'min ? _dateAdapter.toIso8601(min) : null',
// '[attr.max]': 'max ? _dateAdapter.toIso8601(max) : null',
},
providers: [
{provide: NG_VALUE_ACCESSOR, useExisting: MatStartDate, multi: true},
{provide: NG_VALIDATORS, useExisting: MatStartDate, multi: true}
]
})
export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpdateErrorState {
// TODO(crisbeto): start-range-specific validators should go here.
protected _validator = Validators.compose(super._getValidators());
/** Validator that checks whether the start date isn't after the end date. */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wording nit: whether --> that

private _startValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
const start = this._getValidDateOrNull(this._dateAdapter.deserialize(control.value));
const end = this._model ? this._model.selection.end : null;
return (!start || !end ||
this._dateAdapter.compareDate(start, end) <= 0) ?
null : {'matStartDateInvalid': {'end': end, 'actual': start}};
}

protected _validator = Validators.compose([...super._getValidators(), this._startValidator]);

protected _getValueFromModel(modelValue: DateRange<D>) {
return modelValue.start;
Expand Down Expand Up @@ -220,23 +239,29 @@ export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpd
'(keydown)': '_onKeydown($event)',
'[attr.aria-labelledby]': '_rangeInput._ariaLabelledBy',
'[attr.aria-describedby]': '_rangeInput._ariaDescribedBy',
'[attr.aria-haspopup]': '_rangeInput.rangePicker ? "dialog" : null',
'[attr.aria-owns]': '(_rangeInput.rangePicker?.opened && _rangeInput.rangePicker.id) || null',
'[attr.min]': '_getMinDate() ? _dateAdapter.toIso8601(_getMinDate()) : null',
'[attr.max]': '_getMaxDate() ? _dateAdapter.toIso8601(_getMaxDate()) : null',
'(blur)': '_onBlur()',
'type': 'text',

// TODO(crisbeto): to be added once the datepicker is implemented
// '[attr.aria-haspopup]': '_datepicker ? "dialog" : null',
// '[attr.aria-owns]': '(_datepicker?.opened && _datepicker.id) || null',
// '[attr.min]': 'min ? _dateAdapter.toIso8601(min) : null',
// '[attr.max]': 'max ? _dateAdapter.toIso8601(max) : null',
},
providers: [
{provide: NG_VALUE_ACCESSOR, useExisting: MatEndDate, multi: true},
{provide: NG_VALIDATORS, useExisting: MatEndDate, multi: true}
]
})
export class MatEndDate<D> extends _MatDateRangeInputBase<D> implements CanUpdateErrorState {
// TODO(crisbeto): end-range-specific validators should go here.
protected _validator = Validators.compose(super._getValidators());
/** Validator that checks whether the end date isn't before the start date. */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wording nit: see above

private _endValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
const end = this._getValidDateOrNull(this._dateAdapter.deserialize(control.value));
const start = this._model ? this._model.selection.start : null;
return (!end || !start ||
this._dateAdapter.compareDate(end, start) >= 0) ?
null : {'matEndDateInvalid': {'start': start, 'actual': end}};
}

protected _validator = Validators.compose([...super._getValidators(), this._endValidator]);

protected _getValueFromModel(modelValue: DateRange<D>) {
return modelValue.end;
Expand Down
7 changes: 7 additions & 0 deletions src/material/datepicker/date-range-input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ $mat-date-range-input-placeholder-transition:
transition: $mat-date-range-input-placeholder-transition;

.mat-form-field-hide-placeholder & {
// Disable text selection, because the user can click
// through the main label when the input is disabled.
@include user-select(none);
color: transparent;
transition: none;
}
Expand Down Expand Up @@ -57,6 +60,10 @@ $mat-date-range-input-placeholder-transition:
.mat-form-field-hide-placeholder &,
.mat-date-range-input-hide-placeholders & {
@include input-placeholder {
// Disable text selection, because the user can click
// through the main label when the input is disabled.
@include user-select(none);

// Needs to be !important, because the placeholder will end up inheriting the
// input color in IE, if the consumer overrides it with a higher specificity.
color: transparent !important;
Expand Down
26 changes: 19 additions & 7 deletions src/material/datepicker/date-range-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,21 @@ export class MatDateRangeInput<D> implements MatFormFieldControl<DateRange<D>>,
private _max: D | null;

/** Whether the input is disabled. */
@Input()
get disabled(): boolean {
if (this._startInput && this._endInput) {
return this._startInput.disabled && this._endInput.disabled;
}
return (this._startInput && this._endInput) ?
(this._startInput.disabled && this._endInput.disabled) :
this._groupDisabled;
}
set disabled(value: boolean) {
const newValue = coerceBooleanProperty(value);

return false;
if (newValue !== this._groupDisabled) {
this._groupDisabled = newValue;
this._disabledChange.next(this.disabled);
}
}
_groupDisabled = false;

/** Whether the input is in an error state. */
get errorState(): boolean {
Expand Down Expand Up @@ -218,9 +226,12 @@ export class MatDateRangeInput<D> implements MatFormFieldControl<DateRange<D>>,
* @docs-private
*/
onContainerClick(): void {
if (!this.focused) {
// TODO(crisbeto): maybe this should go to end input if start has a value?
this._startInput.focus();
if (!this.focused && !this.disabled) {
if (!this._model || !this._model.selection.start) {
this._startInput.focus();
} else {
this._endInput.focus();
}
}
}

Expand Down Expand Up @@ -317,4 +328,5 @@ export class MatDateRangeInput<D> implements MatFormFieldControl<DateRange<D>>,
}

static ngAcceptInputType_required: BooleanInput;
static ngAcceptInputType_disabled: BooleanInput;
}
22 changes: 20 additions & 2 deletions src/material/datepicker/datepicker-input-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection

/** Whether the datepicker-input is disabled. */
@Input()
get disabled(): boolean { return !!this._disabled; }
get disabled(): boolean { return !!this._disabled || this._parentDisabled(); }
set disabled(value: boolean) {
const newValue = coerceBooleanProperty(value);
const element = this._elementRef.nativeElement;
Expand Down Expand Up @@ -192,6 +192,10 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
this.dateChange.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
this._formatValue(value);

if (this._outsideValueChanged) {
this._outsideValueChanged();
}
}
});
}
Expand All @@ -205,9 +209,15 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
/** Converts a value from the model into a native value for the input. */
protected abstract _getValueFromModel(modelValue: S): D | null;

/** The combined form control validator for this input. */
/** Combined form control validator for this input. */
protected abstract _validator: ValidatorFn | null;

/**
* Callback that'll be invoked when the selection model is changed
* from somewhere that's not the current datepicker input.
*/
protected abstract _outsideValueChanged?: () => void;

/** Whether the last value set on the input was valid. */
protected _lastValueValid = false;

Expand Down Expand Up @@ -330,6 +340,14 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
}
}

/**
* Checks whether a parent control is disabled. This is in place so that it can be overridden
* by inputs extending this one which can be placed inside of a group that can be disabled.
*/
protected _parentDisabled() {
return false;
}

// Accept `any` to avoid conflicts with other directives on `<input>` that
// may accept different types.
static ngAcceptInputType_value: any;
Expand Down
3 changes: 3 additions & 0 deletions src/material/datepicker/datepicker-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ export class MatDatepickerInput<D> extends MatDatepickerInputBase<D | null, D>
return this._dateFilter;
}

// Unnecessary when selecting a single date.
protected _outsideValueChanged: undefined;

// Accept `any` to avoid conflicts with other directives on `<input>` that
// may accept different types.
static ngAcceptInputType_value: any;
Expand Down
Loading