diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html index 1bc0d5964b2a..1f6f5a882802 100644 --- a/src/demo-app/datepicker/datepicker-demo.html +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -30,18 +30,22 @@

Result

+ Too early! + Too late! + Date unavailable! + [startView]="yearView ? 'year' : 'month'">

@@ -49,13 +53,13 @@

Result

[(ngModel)]="date" [min]="minDate" [max]="maxDate" + [mdDatepickerFilter]="filterOdd ? dateFilter : null" placeholder="Pick a date"> + [startView]="yearView ? 'year' : 'month'">

diff --git a/src/demo-app/datepicker/datepicker-demo.ts b/src/demo-app/datepicker/datepicker-demo.ts index 7a70a9c0b4bb..0b2264e380dd 100644 --- a/src/demo-app/datepicker/datepicker-demo.ts +++ b/src/demo-app/datepicker/datepicker-demo.ts @@ -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; } diff --git a/src/lib/core/datetime/date-adapter.ts b/src/lib/core/datetime/date-adapter.ts index 33148d3aa714..938d318339af 100644 --- a/src/lib/core/datetime/date-adapter.ts +++ b/src/lib/core/datetime/date-adapter.ts @@ -140,6 +140,14 @@ export abstract class DateAdapter { */ 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. diff --git a/src/lib/core/datetime/native-date-adapter.ts b/src/lib/core/datetime/native-date-adapter.ts index 7ea5c8e4efac..9e952076eb86 100644 --- a/src/lib/core/datetime/native-date-adapter.ts +++ b/src/lib/core/datetime/native-date-adapter.ts @@ -160,6 +160,14 @@ export class NativeDateAdapter extends DateAdapter { 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); @@ -171,4 +179,13 @@ export class NativeDateAdapter extends DateAdapter { } 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); + } } diff --git a/src/lib/datepicker/datepicker-content.html b/src/lib/datepicker/datepicker-content.html index dd8a696741cb..0f3a70ec7dff 100644 --- a/src/lib/datepicker/datepicker-content.html +++ b/src/lib/datepicker/datepicker-content.html @@ -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)"> diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index 7767fcae98ef..d40868dd69ac 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -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'; @@ -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 implements AfterContentInit, ControlValueAccessor, OnDestroy { +export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor, OnDestroy, + Validator { /** The datepicker that this input is associated with. */ @Input() set mdDatepicker(value: MdDatepicker) { @@ -53,8 +70,17 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces } _datepicker: MdDatepicker; - @Input() - set matDatepicker(value: MdDatepicker) { this.mdDatepicker = value; } + @Input() set matDatepicker(value: MdDatepicker) { 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() @@ -73,20 +99,58 @@ export class MdDatepickerInput 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(); - _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, @@ -106,7 +170,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces this._datepickerSubscription = this._datepicker.selectedChanged.subscribe((selected: D) => { this.value = selected; - this._onChange(selected); + this._cvaOnChange(selected); }); } } @@ -117,6 +181,14 @@ export class MdDatepickerInput 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. @@ -132,7 +204,7 @@ export class MdDatepickerInput 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 @@ -154,7 +226,7 @@ export class MdDatepickerInput 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); } } diff --git a/src/lib/datepicker/datepicker.md b/src/lib/datepicker/datepicker.md index 56278e9931b6..ec235cfcc3dc 100644 --- a/src/lib/datepicker/datepicker.md +++ b/src/lib/datepicker/datepicker.md @@ -60,20 +60,22 @@ startDate = new Date(1990, 0, 1); ``` -### 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 ` => boolean` (where `` 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 ` => boolean` (where `` 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 @@ -82,12 +84,18 @@ maxDate = new Date(2020, 11, 31); ``` ```html - - + + ``` 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 diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index f92e76672fa9..19d91acfbc2e 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -30,6 +30,7 @@ describe('MdDatepicker', () => { ReactiveFormsModule, ], declarations: [ + DatepickerWithFilterAndValidation, DatepickerWithFormControl, DatepickerWithMinAndMax, DatepickerWithNgModel, @@ -428,6 +429,58 @@ describe('MdDatepicker', () => { expect(testComponent.datepicker._maxDate).toEqual(new Date(2020, JAN, 1)); }); }); + + describe('datepicker with filter and validation', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithFilterAndValidation; + + beforeEach(async(() => { + fixture = TestBed.createComponent(DatepickerWithFilterAndValidation); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + })); + + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); + + it('should mark input invalid', async(() => { + testComponent.date = new Date(2017, JAN, 1); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('input')).nativeElement.classList) + .toContain('ng-invalid'); + + testComponent.date = new Date(2017, JAN, 2); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('input')).nativeElement.classList) + .not.toContain('ng-invalid'); + }); + }); + })); + + it('should disable filtered calendar cells', () => { + fixture.detectChanges(); + + testComponent.datepicker.open(); + fixture.detectChanges(); + + expect(document.querySelector('md-dialog-container')).not.toBeNull(); + + let cells = document.querySelectorAll('.mat-calendar-body-cell'); + expect(cells[0].classList).toContain('mat-calendar-body-disabled'); + expect(cells[1].classList).not.toContain('mat-calendar-body-disabled'); + }); + }); }); describe('with missing DateAdapter and MD_DATE_FORMATS', () => { @@ -440,17 +493,7 @@ describe('MdDatepicker', () => { NoopAnimationsModule, ReactiveFormsModule, ], - declarations: [ - DatepickerWithFormControl, - DatepickerWithMinAndMax, - DatepickerWithNgModel, - DatepickerWithStartAt, - DatepickerWithToggle, - InputContainerDatepicker, - MultiInputDatepicker, - NoInputDatepicker, - StandardDatepicker, - ], + declarations: [StandardDatepicker], }); TestBed.compileComponents(); @@ -567,3 +610,17 @@ class DatepickerWithMinAndMax { maxDate = new Date(2020, JAN, 1); @ViewChild('d') datepicker: MdDatepicker; } + + +@Component({ + template: ` + + + + `, +}) +class DatepickerWithFilterAndValidation { + @ViewChild('d') datepicker: MdDatepicker; + date: Date; + filter = (date: Date) => date.getDate() != 1; +} diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 17f8945e2c44..4ba113254244 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -116,9 +116,6 @@ export class MdDatepicker implements OnDestroy { */ @Input() touchUi = false; - /** A function used to filter which dates are selectable. */ - @Input() dateFilter: (date: D) => boolean; - /** Emits new selected date when selected date changes. */ @Output() selectedChanged = new EventEmitter(); @@ -141,6 +138,10 @@ export class MdDatepicker implements OnDestroy { return this._datepickerInput && this._datepickerInput.max; } + get _dateFilter(): (date: D | null) => boolean { + return this._datepickerInput && this._datepickerInput._dateFilter; + } + /** A reference to the overlay when the calendar is opened as a popup. */ private _popupRef: OverlayRef;