Skip to content
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

feat(datepicker): input validation for min, max, and date filter #4393

Merged
merged 5 commits into from
May 5, 2017
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
12 changes: 8 additions & 4 deletions src/demo-app/datepicker/datepicker-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,32 +30,36 @@ <h2>Result</h2>
<button [mdDatepickerToggle]="resultPicker"></button>
<md-input-container>
<input mdInput
#resultPickerModel="ngModel"
[mdDatepicker]="resultPicker"
[(ngModel)]="date"
[min]="minDate"
[max]="maxDate"
[mdDatepickerFilter]="filterOdd ? dateFilter : null"
placeholder="Pick a date">
<md-error *ngIf="resultPickerModel.hasError('mdDatepickerMin')">Too early!</md-error>
<md-error *ngIf="resultPickerModel.hasError('mdDatepickerMax')">Too late!</md-error>
<md-error *ngIf="resultPickerModel.hasError('mdDatepickerFilter')">Date unavailable!</md-error>
</md-input-container>
<md-datepicker
#resultPicker
[touchUi]="touch"
[startAt]="startAt"
[startView]="yearView ? 'year' : 'month'"
[dateFilter]="filterOdd ? dateFilter : null">
[startView]="yearView ? 'year' : 'month'">
</md-datepicker>
</p>
<p>
<input [mdDatepicker]="resultPicker2"
[(ngModel)]="date"
[min]="minDate"
[max]="maxDate"
[mdDatepickerFilter]="filterOdd ? dateFilter : null"
placeholder="Pick a date">
<button [mdDatepickerToggle]="resultPicker2"></button>
<md-datepicker
#resultPicker2
[touchUi]="touch"
[startAt]="startAt"
[startView]="yearView ? 'year' : 'month'"
[dateFilter]="filterOdd ? dateFilter : null">
[startView]="yearView ? 'year' : 'month'">
</md-datepicker>
</p>
2 changes: 1 addition & 1 deletion src/demo-app/datepicker/datepicker-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ export class DatepickerDemo {
maxDate: Date;
startAt: Date;
date: Date;
dateFilter = (date: Date) => date.getMonth() % 2 == 0 && date.getDate() % 2 == 0;
dateFilter = (date: Date) => date.getMonth() % 2 == 1 && date.getDate() % 2 == 0;
}
8 changes: 8 additions & 0 deletions src/lib/core/datetime/date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ export abstract class DateAdapter<D> {
*/
abstract addCalendarDays(date: D, days: number): D;

/**
* Gets the RFC 3339 compatible date string (https://tools.ietf.org/html/rfc3339) for the given
* date.
* @param date The date to get the ISO date string for.
* @returns The ISO date string date string.
*/
abstract getISODateString(date: D): string;

/**
* Sets the locale used for all dates.
* @param locale The new locale.
Expand Down
17 changes: 17 additions & 0 deletions src/lib/core/datetime/native-date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,14 @@ export class NativeDateAdapter extends DateAdapter<Date> {
this.getYear(date), this.getMonth(date), this.getDate(date) + days);
}

getISODateString(date: Date): string {
return [
date.getUTCFullYear(),
this._2digit(date.getUTCMonth() + 1),
this._2digit(date.getUTCDate())
].join('-');
}

/** Creates a date but allows the month and date to overflow. */
private _createDateWithOverflow(year: number, month: number, date: number) {
let result = new Date(year, month, date);
Expand All @@ -171,4 +179,13 @@ export class NativeDateAdapter extends DateAdapter<Date> {
}
return result;
}

/**
* Pads a number to make it two digits.
* @param n The number to pad.
* @returns The padded number.
*/
private _2digit(n: number) {
return ('00' + n).slice(-2);
}
}
2 changes: 1 addition & 1 deletion src/lib/datepicker/datepicker-content.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[startView]="datepicker.startView"
[minDate]="datepicker._minDate"
[maxDate]="datepicker._maxDate"
[dateFilter]="datepicker.dateFilter"
[dateFilter]="datepicker._dateFilter"
[selected]="datepicker._selected"
(selectedChange)="datepicker._selectAndClose($event)">
</md-calendar>
100 changes: 86 additions & 14 deletions src/lib/datepicker/datepicker-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,16 @@ import {
Renderer
} from '@angular/core';
import {MdDatepicker} from './datepicker';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {
AbstractControl,
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
ValidatorFn,
Validators
} from '@angular/forms';
import {Subscription} from 'rxjs/Subscription';
import {MdInputContainer} from '../input/input-container';
import {DOWN_ARROW} from '../core/keyboard/keycodes';
Expand All @@ -27,22 +36,30 @@ export const MD_DATEPICKER_VALUE_ACCESSOR: any = {
};


export const MD_DATEPICKER_VALIDATORS: any = {
provide: NG_VALIDATORS,
useExisting: forwardRef(() => MdDatepickerInput),
multi: true
};


/** Directive used to connect an input to a MdDatepicker. */
@Directive({
selector: 'input[mdDatepicker], input[matDatepicker]',
providers: [MD_DATEPICKER_VALUE_ACCESSOR],
providers: [MD_DATEPICKER_VALUE_ACCESSOR, MD_DATEPICKER_VALIDATORS],
host: {
'[attr.aria-expanded]': '_datepicker?.opened || "false"',
'[attr.aria-haspopup]': 'true',
'[attr.aria-owns]': '_datepicker?.id',
'[min]': '_min',
'[max]': '_max',
'[attr.min]': 'min ? _dateAdapter.getISODateString(min) : null',
'[attr.max]': 'max ? _dateAdapter.getISODateString(max) : null',
'(input)': '_onInput($event.target.value)',
'(blur)': '_onTouched()',
'(keydown)': '_onKeydown($event)',
}
})
export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAccessor, OnDestroy {
export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAccessor, OnDestroy,
Validator {
/** The datepicker that this input is associated with. */
@Input()
set mdDatepicker(value: MdDatepicker<D>) {
Expand All @@ -53,8 +70,17 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
}
_datepicker: MdDatepicker<D>;

@Input()
set matDatepicker(value: MdDatepicker<D>) { this.mdDatepicker = value; }
@Input() set matDatepicker(value: MdDatepicker<D>) { this.mdDatepicker = value; }

@Input() set mdDatepickerFilter(filter: (date: D | null) => boolean) {
this._dateFilter = filter;
this._validatorOnChange();
}
_dateFilter: (date: D | null) => boolean;

@Input() set matDatepickerFilter(filter: (date: D | null) => boolean) {
this.mdDatepickerFilter = filter;
}

/** The value of the input. */
@Input()
Expand All @@ -73,20 +99,58 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
}

/** The minimum valid date. */
@Input() min: D;
@Input()
get min(): D { return this._min; }
set min(value: D) {
this._min = value;
this._validatorOnChange();
}
private _min: D;

/** The maximum valid date. */
@Input() max: D;
@Input()
get max(): D { return this._max; }
set max(value: D) {
this._max = value;
this._validatorOnChange();
}
private _max: D;

/** Emits when the value changes (either due to user input or programmatic change). */
_valueChange = new EventEmitter<D>();

_onChange = (value: any) => {};

_onTouched = () => {};

private _cvaOnChange = (value: any) => {};

private _validatorOnChange = () => {};

private _datepickerSubscription: Subscription;

/** The form control validator for the min date. */
private _minValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
return (!this.min || !control.value ||
this._dateAdapter.compareDate(this.min, control.value) < 0) ?
null : {'mdDatepickerMin': {'min': this.min, 'actual': control.value}};
}

/** The form control validator for the max date. */
private _maxValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
return (!this.max || !control.value ||
this._dateAdapter.compareDate(this.max, control.value) > 0) ?
null : {'mdDatepickerMax': {'max': this.max, 'actual': control.value}};
}

/** The form control validator for the date filter. */
private _filterValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
return !this._dateFilter || !control.value || this._dateFilter(control.value) ?
null : {'mdDatepickerFilter': true};
}

/** The combined form control validator for this input. */
private _validator: ValidatorFn =
Validators.compose([this._minValidator, this._maxValidator, this._filterValidator]);

constructor(
private _elementRef: ElementRef,
private _renderer: Renderer,
Expand All @@ -106,7 +170,7 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
this._datepickerSubscription =
this._datepicker.selectedChanged.subscribe((selected: D) => {
this.value = selected;
this._onChange(selected);
this._cvaOnChange(selected);
});
}
}
Expand All @@ -117,6 +181,14 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
}
}

registerOnValidatorChange(fn: () => void): void {
this._validatorOnChange = fn;
}

validate(c: AbstractControl): ValidationErrors | null {
return this._validator ? this._validator(c) : null;
}

/**
* Gets the element that the datepicker popup should be connected to.
* @return The element to connect the popup to.
Expand All @@ -132,7 +204,7 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces

// Implemented as part of ControlValueAccessor
registerOnChange(fn: (value: any) => void): void {
this._onChange = fn;
this._cvaOnChange = fn;
}

// Implemented as part of ControlValueAccessor
Expand All @@ -154,7 +226,7 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces

_onInput(value: string) {
let date = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput);
this._onChange(date);
this._cvaOnChange(date);
this._valueChange.emit(date);
}
}
36 changes: 22 additions & 14 deletions src/lib/datepicker/datepicker.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,22 @@ startDate = new Date(1990, 0, 1);
<md-datepicker startView="year" [startAt]="startDate"></md-datepicker>
```

### Preventing selection of specific dates
There are two ways to restrict the dates available for selection in the datepicker. The first is by
using the `min` and `max` properties of the input. This will disable all dates on the calendar
before or after the respective given dates. It will also prevent the user from advancing the
calendar past the `month` or `year` (depending on current view) containing the `min` or `max` date.
### Date validation
There are three properties that add date validation to the datepicker input. The first two are the
`min` and `max` properties. In addition to enforcing validation on the input, these properties will
disable all dates on the calendar popup before or after the respective values and prevent the user
from advancing the calendar past the `month` or `year` (depending on current view) containing the
`min` or `max` date.

The second way to restrict selection is using the `dateFilter` property of `md-datepicker`. The
`dateFilter` property accepts a function of `<D> => boolean` (where `<D>` is the date type used by
The second way to add date validation is using the `mdDatepickerFilter` property of the datepicker
input. This property accepts a function of `<D> => boolean` (where `<D>` is the date type used by
the datepicker, see section on
[choosing a date implementation](#choosing-a-date-implementation-and-date-format-settings)).
A result of `true` indicates that the date is selectable and a result of `false` indicates that it
is not. One important difference between using `dateFilter` vs using `min` or `max` is that
filtering out all dates before a certain point, will not prevent the user from advancing the
calendar past that point.
A result of `true` indicates that the date is valid and a result of `false` indicates that it is
not. Again this will also disable the dates on the calendar that are invalid. However, one important
difference between using `mdDatepickerFilter` vs using `min` or `max` is that filtering out all
dates before or after a certain point, will not prevent the user from advancing the calendar past
that point.

```ts
myFilter = (d: Date) => d.getFullYear() > 2005
Expand All @@ -82,12 +84,18 @@ maxDate = new Date(2020, 11, 31);
```

```html
<input [mdDatepicker]="d" [min]="minDate" [max]="maxDate">
<md-datepicker #d [dateFilter]="myFilter"></md-datepicker>
<input [mdDatepicker]="d" [mdDatepickerFilter]="myFilter" [min]="minDate" [max]="maxDate" ngModel>
<md-datepicker #d></md-datepicker>
```

In this example the user can back past 2005, but all of the dates before then will be unselectable.
They will not be able to go further back in the calendar than 2000.
They will not be able to go further back in the calendar than 2000. If they manually type in a date
that is before the min, after the max, or filtered out, the input will have validation errors.

Each validation property has a different error that can be checked:
* A value that violates the `min` property will have a `mdDatepickerMin` error.
* A value that violates the `max` property will have a `mdDatepickerMax` error.
* A value that violates the `mdDatepickerFilter` property will have a `mdDatepickerFilter` error.

### Touch UI mode
The datepicker normally opens as a popup under the input. However this is not ideal for touch
Expand Down
Loading