angular-mat-datepicker
Version:
[](https://www.npmjs.org/package/angular2-material-datepicker) [](https://travis-ci.org/koleary9
771 lines (702 loc) • 24.5 kB
text/typescript
import {
animate, Component, ElementRef, EventEmitter, Input, keyframes, OnChanges,
OnInit, Output, Renderer, SimpleChange, state, style, transition, trigger,
forwardRef
} from '@angular/core';
import { FormControl, Validators, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Calendar } from './calendar';
import * as moment from 'moment';
interface DateFormatFunction {
(date: Date): string;
}
interface ValidationResult {
[key: string]: boolean;
}
({
selector: 'material-datepicker',
animations: [
trigger('calendarAnimation', [
transition('* => left', [
animate(180, keyframes([
style({ transform: 'translateX(105%)', offset: 0.5 }),
style({ transform: 'translateX(-130%)', offset: 0.51 }),
style({ transform: 'translateX(0)', offset: 1 })
]))
]),
transition('* => right', [
animate(180, keyframes([
style({ transform: 'translateX(-105%)', offset: 0.5 }),
style({ transform: 'translateX(130%)', offset: 0.51 }),
style({ transform: 'translateX(0)', offset: 1 })
]))
])
])
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DatepickerComponent),
multi: true
}
],
styles: [
`.datepicker {
position: relative;
display: inline-block;
color: #2b2b2b;
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', 'Calibri', 'Roboto';
}
.datepicker__calendar {
position: absolute;
overflow: hidden;
z-index: 1000;
top: 1.9em;
left: 0;
height: 23.8em;
width: 20.5em;
font-size: 14px;
background-color: #ffffff;
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
cursor: default;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.datepicker__calendar__cancel {
position: absolute;
bottom: 1em;
left: 1.8em;
color: #d8d8d8;
cursor: pointer;
-webkit-transition: 0.37s;
transition: 0.37s;
}
.datepicker__calendar__cancel:hover {
color: #b1b1b1;
}
.datepicker__calendar__content {
margin-top: 0.4em;
}
.datepicker__calendar__labels {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
width: 100%;
}
.datepicker__calendar__label {
display: inline-block;
width: 2.2em;
height: 2.2em;
margin: 0 0.2em;
line-height: 2.2em;
text-align: center;
color: #d8d8d8;
}
.datepicker__calendar__month {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-ms-flex-flow: wrap;
flex-flow: wrap;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.datepicker__calendar__month__day {
display: inline-block;
width: 2.2em;
height: 2.2em;
margin: 0 0.2em 0.4em;
border-radius: 2.2em;
line-height: 2.2em;
text-align: center;
-webkit-transition: 0.37s;
transition: 0.37s;
}
.datepicker__calendar__nav {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
height: 3em;
background-color: #fff;
border-bottom: 1px solid #e8e8e8;
}
.datepicker__calendar__nav__arrow {
width: 0.8em;
height: 0.8em;
cursor: pointer;
-webkit-transition: 0.37s;
transition: 0.37s;
}
.datepicker__calendar__nav__arrow:hover {
-webkit-transform: scale(1.05);
transform: scale(1.05);
}
.datepicker__calendar__nav__chevron {
fill: #bbbbbb;
-webkit-transition: 0.37s;
transition: 0.37s;
}
.datepicker__calendar__nav__chevron:hover {
fill: #2b2b2b;
}
.datepicker__calendar__nav__header {
width: 11em;
margin: 0 1em;
text-align: center;
}
.datepicker__calendar__nav__header__form {
display: inline-block;
margin: 0;
}
.datepicker__calendar__nav__header__year {
display: inline-block;
width: 3em;
padding: 2px 4px;
border: 1px solid #ffffff;
border-radius: 2px;
font-size: 1em;
transition: 0.32s;
}
.datepicker__calendar__nav__header__year:focus.ng-invalid {
border: 1px solid #e82525;
}
.datepicker__calendar__nav__header__year:focus.ng-valid {
border: 1px solid #13ad13;
}
.datepicker__calendar__nav__header__year:focus {
outline: none;
}
.datepicker__input {
outline: none;
border-radius: 0.1rem;
padding: .2em .6em;
font-size: 14px;
}
`
],
template: `
<div
class="datepicker"
[ngStyle]="{'font-family': fontFamily}"
>
<input
[disabled]="disabled"
class="datepicker__input"
[placeholder]="placeholder"
[ngStyle]="{'color': altInputStyle ? colors['white'] : colors['black'],
'background-color': altInputStyle ? accentColor : colors['white'],
'border': altInputStyle ? '' : '1px solid #dadada'}"
(click)="onInputClick()"
[(ngModel)]="inputText"
readonly="true"
>
<div
class="datepicker__calendar"
*ngIf="showCalendar"
>
<div class="datepicker__calendar__nav">
<div
class="datepicker__calendar__nav__arrow"
(click)="onArrowClick('left')"
>
<svg class="datepicker__calendar__nav__chevron" x="0px" y="0px" viewBox="0 0 50 50">
<g>
<path d="M39.7,7.1c0.5,0.5,0.5,1.2,0,1.7L29,19.6c-0.5,0.5-1.2,1.2-1.7,1.7L16.5,32.1c-0.5,0.5-1.2,0.5-1.7,0l-2.3-2.3
c-0.5-0.5-1.2-1.2-1.7-1.7l-2.3-2.3c-0.5-0.5-0.5-1.2,0-1.7l10.8-10.8c0.5-0.5,1.2-1.2,1.7-1.7L31.7,0.8c0.5-0.5,1.2-0.5,1.7,0
l2.3,2.3c0.5,0.5,1.2,1.2,1.7,1.7L39.7,7.1z"/>
</g>
<g>
<path d="M33.4,49c-0.5,0.5-1.2,0.5-1.7,0L20.9,38.2c-0.5-0.5-1.2-1.2-1.7-1.7L8.4,25.7c-0.5-0.5-0.5-1.2,0-1.7l2.3-2.3
c0.5-0.5,1.2-1.2,1.7-1.7l2.3-2.3c0.5-0.5,1.2-0.5,1.7,0l10.8,10.8c0.5,0.5,1.2,1.2,1.7,1.7l10.8,10.8c0.5,0.5,0.5,1.2,0,1.7
L37.4,45c-0.5,0.5-1.2,1.2-1.7,1.7L33.4,49z"/>
</g>
</svg>
</div>
<div class="datepicker__calendar__nav__header">
<span>{{ currentMonth }}</span>
<input
#yearInput
class="datepicker__calendar__nav__header__year"
placeholder="Year"
[formControl]="yearControl"
(keyup.enter)="yearInput.blur()"
(blur)="onYearSubmit()"
/>
</div>
<div
class="datepicker__calendar__nav__arrow"
(click)="onArrowClick('right')"
>
<svg class="datepicker__calendar__nav__chevron" x="0px" y="0px" viewBox="0 0 50 50">
<g>
<path d="M8.4,7.1c-0.5,0.5-0.5,1.2,0,1.7l10.8,10.8c0.5,0.5,1.2,1.2,1.7,1.7l10.8,10.8c0.5,0.5,1.2,0.5,1.7,0l2.3-2.3
c0.5-0.5,1.2-1.2,1.7-1.7l2.3-2.3c0.5-0.5,0.5-1.2,0-1.7L29,13.2c-0.5-0.5-1.2-1.2-1.7-1.7L16.5,0.8c-0.5-0.5-1.2-0.5-1.7,0
l-2.3,2.3c-0.5,0.5-1.2,1.2-1.7,1.7L8.4,7.1z"/>
</g>
<g>
<path d="M14.8,49c0.5,0.5,1.2,0.5,1.7,0l10.8-10.8c0.5-0.5,1.2-1.2,1.7-1.7l10.8-10.8c0.5-0.5,0.5-1.2,0-1.7l-2.3-2.3
c-0.5-0.5-1.2-1.2-1.7-1.7l-2.3-2.3c-0.5-0.5-1.2-0.5-1.7,0L20.9,28.5c-0.5,0.5-1.2,1.2-1.7,1.7L8.4,40.9c-0.5,0.5-0.5,1.2,0,1.7
l2.3,2.3c0.5,0.5,1.2,1.2,1.7,1.7L14.8,49z"/>
</g>
</svg>
</div>
</div>
<div
class="datepicker__calendar__content"
>
<div class="datepicker__calendar__labels">
<div
class="datepicker__calendar__label"
*ngFor="let day of dayNamesOrdered"
>
{{ day }}
</div>
</div>
<div
[@calendarAnimation]="animate"
class="datepicker__calendar__month"
>
<div
*ngFor="let day of calendarDays"
class="datepicker__calendar__month__day"
[ngStyle]="{'cursor': day == 0 ? 'initial' : 'pointer',
'background-color': getDayBackgroundColor(day),
'color': isHoveredDay(day) ? accentColor : getDayFontColor(day),
'pointer-events': day == 0 ? 'none' : ''
}"
(click)="onSelectDay(day)"
(mouseenter)="hoveredDay = day"
(mouseleave)="hoveredDay = null"
>
<span *ngIf="day != 0">
{{ day.getDate() }}
</span>
</div>
</div>
<div
class="datepicker__calendar__cancel"
(click)="onCancel()"
>
{{cancelText}}
</div>
</div>
</div>
</div>
`
})
export class DatepickerComponent implements OnInit, OnChanges, ControlValueAccessor {
onTouched: any;
private readonly DEFAULT_FORMAT = 'YYYY-MM-DD';
private dateVal: Date;
// two way bindings
() dateChange = new EventEmitter<Date>();
() get date(): Date { return this.dateVal; };
set date(val: Date) {
this.dateVal = val;
this.dateChange.emit(val);
}
// api bindings
() disabled: boolean;
() accentColor: string;
() altInputStyle: boolean;
() dateFormat: string | DateFormatFunction;
() fontFamily: string;
() rangeStart: Date;
() rangeEnd: Date;
// data
() placeholder: string = 'Select a date';
() inputText: string;
// view logic
() showCalendar: boolean;
() cancelText: string = 'Cancel';
() weekStart: number = 0;
// events
() onSelect = new EventEmitter<Date>();
// time
() calendarDays: Array<number>;
() currentMonth: string;
() dayNames: Array<String> = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; // Default order: firstDayOfTheWeek = 0
() hoveredDay: Date;
() months: Array<string>;
dayNamesOrdered: Array<String>;
calendar: Calendar;
currentMonthNumber: number;
currentYear: number;
// animation
animate: string;
// colors
colors: { [id: string]: string };
// listeners
clickListener: Function;
// forms
yearControl: FormControl;
//Function to be called on date selection
changeValue: any;
constructor(private renderer: Renderer, private elementRef: ElementRef) {
this.dateFormat = this.DEFAULT_FORMAT;
// view logic
this.showCalendar = false;
// colors
this.colors = {
'black': '#333333',
'blue': '#1285bf',
'lightGrey': '#f1f1f1',
'white': '#ffffff'
};
this.accentColor = this.colors['blue'];
this.altInputStyle = false;
// time
this.updateDayNames();
this.months = [
'January', 'February', 'March', 'April', 'May', 'June', 'July',
'August', 'September', 'October', 'November', ' December'
];
// listeners
this.clickListener = renderer.listenGlobal(
'document',
'click',
(event: MouseEvent) => this.handleGlobalClick(event)
);
// form controls
this.yearControl = new FormControl('', Validators.compose([
Validators.required,
Validators.maxLength(4),
this.yearValidator,
this.inRangeValidator.bind(this)
]));
}
ngOnInit() {
this.updateDayNames();
this.syncVisualsWithDate();
}
ngOnChanges(changes: { [propertyName: string]: SimpleChange }) {
if ((changes['date'] || changes['dateFormat'])) {
this.syncVisualsWithDate();
}
if (changes['firstDayOfTheWeek'] || changes['dayNames']) {
this.updateDayNames();
}
}
ngOnDestroy() {
this.clickListener();
}
// -------------------------------------------------------------------------------- //
// -------------------------------- State Management ------------------------------ //
// -------------------------------------------------------------------------------- //
/**
* Closes the calendar and syncs visual components with selected or current date.
* This way if a user opens the calendar with this month, scrolls to two months from now,
* closes the calendar, then reopens it, it will open with the current month
* or month associated with the selected date
*/
closeCalendar(): void {
this.showCalendar = false;
this.syncVisualsWithDate();
}
/**
* Sets the date values associated with the ui
*/
private setCurrentValues(date: Date) {
this.currentMonthNumber = date.getMonth();
this.currentMonth = this.months[this.currentMonthNumber];
this.currentYear = date.getFullYear();
this.yearControl.setValue(this.currentYear);
const calendarArray = this.calendar.monthDays(this.currentYear, this.currentMonthNumber);
this.calendarDays = [].concat.apply([], calendarArray);
this.calendarDays = this.filterInvalidDays(this.calendarDays);
}
/**
* Update the day names order. The order can be modified with the firstDayOfTheWeek input, while 0 means that the
* first day will be sunday.
*/
private updateDayNames() {
this.dayNamesOrdered = this.dayNames.slice(); // Copy DayNames with default value (weekStart = 0)
if (this.weekStart < 0 || this.weekStart >= this.dayNamesOrdered.length) {
// Out of range
throw Error(`The weekStart is not in range between ${0} and ${this.dayNamesOrdered.length - 1}`)
} else {
this.calendar = new Calendar(this.weekStart);
this.dayNamesOrdered = this.dayNamesOrdered.slice(this.weekStart, this.dayNamesOrdered.length)
.concat(this.dayNamesOrdered.slice(0, this.weekStart)); // Append beginning to end
}
}
/**
* Visually syncs calendar and input to selected date or current day
*/
syncVisualsWithDate(): void {
if (this.date) {
this.setInputText(this.date);
this.setCurrentValues(this.date);
} else {
this.inputText = '';
this.setCurrentValues(new Date());
}
}
/**
* Sets the currentMonth and creates new calendar days for the given month
*/
setCurrentMonth(monthNumber: number): void {
this.currentMonth = this.months[monthNumber];
const calendarArray = this.calendar.monthDays(this.currentYear, this.currentMonthNumber);
this.calendarDays = [].concat.apply([], calendarArray);
this.calendarDays = this.filterInvalidDays(this.calendarDays);
}
/**
* Sets the currentYear and FormControl value associated with the year
*/
setCurrentYear(year: number): void {
this.currentYear = year;
this.yearControl.setValue(year);
}
/**
* Sets the visible input text
*/
setInputText(date: Date): void {
let inputText = "";
const dateFormat: string | DateFormatFunction = this.dateFormat;
if (dateFormat === undefined || dateFormat === null) {
inputText = moment(date).format(this.DEFAULT_FORMAT);
} else if (typeof dateFormat === 'string') {
inputText = moment(date).format(dateFormat);
} else if (typeof dateFormat === 'function') {
inputText = dateFormat(date);
}
this.inputText = inputText;
}
// -------------------------------------------------------------------------------- //
// --------------------------------- Click Handlers ------------------------------- //
// -------------------------------------------------------------------------------- //
/**
* Sets the date values associated with the calendar.
* Triggers animation if the month changes
*/
onArrowClick(direction: string): void {
const currentMonth: number = this.currentMonthNumber;
let newYear: number = this.currentYear;
let newMonth: number;
// sets the newMonth
// changes newYear is necessary
if (direction === 'left') {
if (currentMonth === 0) {
newYear = this.currentYear - 1;
newMonth = 11;
} else {
newMonth = currentMonth - 1;
}
} else if (direction === 'right') {
if (currentMonth === 11) {
newYear = this.currentYear + 1;
newMonth = 0;
} else {
newMonth = currentMonth + 1;
}
}
// check if new date would be within range
let newDate = new Date(newYear, newMonth);
let newDateValid: boolean;
if (direction === 'left') {
newDateValid = !this.rangeStart || newDate.getTime() >= this.rangeStart.getTime();
} else if (direction === 'right') {
newDateValid = !this.rangeEnd || newDate.getTime() <= this.rangeEnd.getTime();
}
if (newDateValid) {
this.setCurrentYear(newYear);
this.currentMonthNumber = newMonth;
this.setCurrentMonth(newMonth);
this.triggerAnimation(direction);
}
}
/**
* Check if a date is within the range.
* @param date The date to check.
* @return true if the date is within the range, false if not.
*/
isDateValid(date: Date): boolean {
return (!this.rangeStart || date.getTime() >= this.rangeStart.getTime()) &&
(!this.rangeEnd || date.getTime() <= this.rangeEnd.getTime());
}
/**
* Filter out the days that are not in the date range.
* @param calendarDays The calendar days
* @return {Array} The input with the invalid days replaced by 0
*/
filterInvalidDays(calendarDays: Array<number>): Array<number> {
let newCalendarDays = [];
calendarDays.forEach((day: number | Date) => {
if (day === 0 || !this.isDateValid(<Date> day)) {
newCalendarDays.push(0)
} else {
newCalendarDays.push(day)
}
});
return newCalendarDays;
}
/**
* Closes the calendar when the cancel button is clicked
*/
onCancel(): void {
this.closeCalendar();
}
/**
* Toggles the calendar when the date input is clicked
*/
onInputClick(): void {
this.showCalendar = !this.showCalendar;
}
/**
* Returns the font color for a day
*/
onSelectDay(day: Date): void {
if (this.isDateValid(day)) {
this.date = day;
this.onSelect.emit(day);
this.showCalendar = !this.showCalendar;
this.changeValue(day);
}
}
/**
* Sets the current year and current month if the year from
* yearControl is valid
*/
onYearSubmit(): void {
if (this.yearControl.valid && +this.yearControl.value !== this.currentYear) {
this.setCurrentYear(+this.yearControl.value);
this.setCurrentMonth(this.currentMonthNumber);
} else {
this.yearControl.setValue(this.currentYear);
}
}
// -------------------------------------------------------------------------------- //
// ----------------------------------- Listeners ---------------------------------- //
// -------------------------------------------------------------------------------- //
/**
* Closes the calendar if a click is not within the datepicker component
*/
handleGlobalClick(event: MouseEvent): void {
const withinElement = this.elementRef.nativeElement.contains(event.target);
if (!this.elementRef.nativeElement.contains(event.target)) {
this.closeCalendar();
}
}
// -------------------------------------------------------------------------------- //
// ----------------------------------- Helpers ------------------------------------ //
// -------------------------------------------------------------------------------- //
/**
* Returns the background color for a day
*/
getDayBackgroundColor(day: Date): string {
let color = this.colors['white'];
if (this.isChosenDay(day)) {
color = this.accentColor;
} else if (this.isCurrentDay(day)) {
color = this.colors['lightGrey'];
}
return color;
}
/**
* Returns the font color for a day
*/
getDayFontColor(day: Date): string {
let color = this.colors['black'];
if (this.isChosenDay(day)) {
color = this.colors['white'];
}
return color;
}
/**
* Returns whether a day is the chosen day
*/
isChosenDay(day: Date): boolean {
if (day) {
return this.date ? day.toDateString() === this.date.toDateString() : false;
} else {
return false;
}
}
/**
* Returns whether a day is the current calendar day
*/
isCurrentDay(day: Date): boolean {
if (day) {
return day.toDateString() === new Date().toDateString();
} else {
return false;
}
}
/**
* Returns whether a day is the day currently being hovered
*/
isHoveredDay(day: Date): boolean {
return this.hoveredDay ? this.hoveredDay === day && !this.isChosenDay(day) : false;
}
/**
* Triggers an animation and resets to initial state after the duration of the animation
*/
triggerAnimation(direction: string): void {
this.animate = direction;
setTimeout(() => this.animate = 'reset', 185);
}
// -------------------------------------------------------------------------------- //
// ---------------------------------- Validators ---------------------------------- //
// -------------------------------------------------------------------------------- //
/**
* Validates that a value is within the 'rangeStart' and/or 'rangeEnd' if specified
*/
inRangeValidator(control: FormControl): ValidationResult {
const value = control.value;
if (this.currentMonthNumber) {
const tentativeDate = new Date(+value, this.currentMonthNumber);
if (this.rangeStart && tentativeDate.getTime() < this.rangeStart.getTime()) {
return { 'yearBeforeRangeStart': true };
}
if (this.rangeEnd && tentativeDate.getTime() > this.rangeEnd.getTime()) {
return { 'yearAfterRangeEnd': true };
}
return null;
}
return { 'currentMonthMissing': true };
}
/**
* Validates that a value is a number greater than or equal to 1970
*/
yearValidator(control: FormControl): ValidationResult {
const value = control.value;
const valid = !isNaN(value) && value >= 1970 && Math.floor(value) === +value;
if (valid) {
return null;
}
return { 'invalidYear': true };
}
// -------------------------------------------------------------------------------- //
// --------------------------- Control Value Accessor ----------------------------- //
// -------------------------------------------------------------------------------- //
/**
* Set of functions to implement ControlValueAccessor interface, allowing this to be
* used with Angular/forms
*/
writeValue(obj: any): void {
this.date = obj;
this.syncVisualsWithDate();
}
registerOnChange(fn: any): void {
this.changeValue = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
}