ben tedder : code things

Create a calendar grid component in Angular 4

Today we're going to generate a calendar that shows a grid of dates and lets you navigate back and forth between months and years. If you want to skip ahead, check out the full source code here (includes the spec file!).

Requirements

  • navigation to previous and next month
  • navigation to previous and next year
  • dates in current month are selectable
  • dates in previous and next months are visually different
  • dates in previous and next months cannot be selected

Considerations

  • Use a standard 6-week calendar layout so that the grid is a consistent size
  • The calendar grid should accept an @Input of "selected" dates
  • The calendar should @Output the date selected through an event emitter

Angular Component - Calendar

Let's walk through how to structure this component. The first problems of navigating to previous months/years is as simple as storing the currentDate on the component:

1. Use moment() to get current month/year

Something I didn't mention before, but is critical: use moment.js, don't bother with anything else. It's a crazy good library. One of the best ones I've worked with. We are only ever going to use the year and month of this.currentDate in our component.

export class CalendarComponent implements OnInit, OnChanges {
  ...
  currentDate = moment();
  ...
}

2. Navigate back/forth between month/year

A sample below. Basically when you click the prev/next button we alter this.currentDate to be the next/prev month. Same thing applies to year. Use .add() and .subtract() from moment.js to have the magic done for you.

prevMonth(): void {
  this.currentDate = moment(this.currentDate).subtract(1, 'months');
}
nextMonth(): void {
  this.currentDate = moment(this.currentDate).add(1, 'months');
}

3. Generate the calendar grid

This is the real magic. We're going to have 2 methods, one to generate the weeks, with a helper to fill the dates. Mostly split up for readability. Let's walk through each of them.

First, we'll create an interface for a CalendarDate, which is the type of object that we'll be rendering. The date itself will be a moment object, and it will have two boolean flags to determine which classes it gets when rendered in the UI.

export interface CalendarDate {
  mDate: moment.Moment;
  selected?: boolean;
  today?: boolean;
}

Next we'll generate the calendar by filling in the dates in the grid and splitting them into weeks. This step is arguably unnecessary, because the weeks are only used in the UI. It may be simpler to just have an array of days and deal with wrapping in the UI. For now this is how it is though.

export class CalendarComponent implements OnInit, OnChanges {
  ...
  generateCalendar(): void {
    const dates = this.fillDates(this.currentDate);
    const weeks: CalendarDate[][] = [];
    while (dates.length > 0) {
      weeks.push(dates.splice(0, 7));
    }
    this.weeks = weeks;
  }
  ...
}

Finally we'll create the fillDates method. This method gets the "Day of the week" index from the first day of the current month, then it figures out the date on which to start the calendar so that it always starts on a Sunday (index 0). Once we get the start date, moment.js actually makes it pretty easy. If you ask moment to give you the date for the 42nd day of September it will return: Oct 12. So knowing that we need 42 days (6 weeks) on the calendar, all we need to do is ask it for each date from the first date and it will fill in our array. Check it out. The other pieces are just mapping the results to match the interface we're expecting.

fillDates(currentMoment: moment.Moment): CalendarDate[] {
  const firstOfMonth = moment(currentMoment).startOf('month').day();
  const firstDayOfGrid = moment(currentMoment).startOf('month').subtract(firstOfMonth, 'days');
  const start = firstDayOfGrid.date();
  return _.range(start, start + 42)
          .map((date: number): CalendarDate => {
            const d = moment(firstDayOfGrid).date(date);
            return {
              today: this.isToday(d),
              selected: this.isSelected(d),
              mDate: d,
            };
          });
}

I've left out some explanations of things like figuring out isSelected or isCurrentMonth etc but you can see it all in the source code.

Here's the final typescript component:

import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
import * as moment from 'moment';
import * as _ from 'lodash';

export interface CalendarDate { mDate: moment.Moment; selected?: boolean; today?: boolean; }

@Component({ selector: 'yoshimi-calendar', templateUrl: './calendar.component.html', }) export class CalendarComponent implements OnInit, OnChanges {

currentDate = moment(); dayNames = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; weeks: CalendarDate[][] = []; sortedDates: CalendarDate[] = [];

@Input() selectedDates: CalendarDate[] = []; @Output() onSelectDate = new EventEmitter<CalendarDate>();

constructor() {}

ngOnInit(): void { this.generateCalendar(); }

ngOnChanges(changes: SimpleChanges): void { if (changes.selectedDates && changes.selectedDates.currentValue && changes.selectedDates.currentValue.length > 1) { // sort on date changes for better performance when range checking this.sortedDates = _.sortBy(changes.selectedDates.currentValue, (m: CalendarDate) => m.mDate.valueOf()); this.generateCalendar(); } }

// date checkers

isToday(date: moment.Moment): boolean { return moment().isSame(moment(date), 'day'); }

isSelected(date: moment.Moment): boolean { return _.findIndex(this.selectedDates, (selectedDate) => { return moment(date).isSame(selectedDate.mDate, 'day'); }) > -1; }

isSelectedMonth(date: moment.Moment): boolean { return moment(date).isSame(this.currentDate, 'month'); }

selectDate(date: CalendarDate): void { this.onSelectDate.emit(date); }

// actions from calendar

prevMonth(): void { this.currentDate = moment(this.currentDate).subtract(1, 'months'); this.generateCalendar(); }

nextMonth(): void { this.currentDate = moment(this.currentDate).add(1, 'months'); this.generateCalendar(); }

firstMonth(): void { this.currentDate = moment(this.currentDate).startOf('year'); this.generateCalendar(); }

lastMonth(): void { this.currentDate = moment(this.currentDate).endOf('year'); this.generateCalendar(); }

prevYear(): void { this.currentDate = moment(this.currentDate).subtract(1, 'year'); this.generateCalendar(); }

nextYear(): void { this.currentDate = moment(this.currentDate).add(1, 'year'); this.generateCalendar(); }

// generate the calendar grid

generateCalendar(): void { const dates = this.fillDates(this.currentDate); const weeks: CalendarDate[][] = []; while (dates.length > 0) { weeks.push(dates.splice(0, 7)); } this.weeks = weeks; }

fillDates(currentMoment: moment.Moment): CalendarDate[] { const firstOfMonth = moment(currentMoment).startOf('month').day(); const firstDayOfGrid = moment(currentMoment).startOf('month').subtract(firstOfMonth, 'days'); const start = firstDayOfGrid.date(); return _.range(start, start + 42) .map((date: number): CalendarDate => { const d = moment(firstDayOfGrid).date(date); return { today: this.isToday(d), selected: this.isSelected(d), mDate: d, }; }); } }

Setup HTML

The html is pretty straightforward. Our html is going to have a few sections:

  • prev/next month
  • name of current month
  • prev/next year
  • name of current year
  • calendar grid
    • iterate over weeks to create grid
    • iterate over days to create weeks

<div class="calendar">
  <div class="calendar-navs">
    <div class="month-nav">
      <button (click)="prevMonth()">&lt;</button>
      <span class="p4">{{ currentDate.format('MMMM') }}</span>
      <button (click)="nextMonth()">&gt;</button>
    </div>
    <div class="year-nav">
      <button (click)="prevYear()">&lt;</button>
      <span>{{ currentDate.format('YYYY') }}</span>
      <button (click)="nextYear()">&gt;</button>
    </div>
  </div>
  <div class="month-grid">
    <div class="day-names">
      <div *ngFor="let name of dayNames" class="day-name p9">
        {{ name }}
      </div>
    </div>
    <div class="weeks">
      <div *ngFor="let week of weeks" class="week">
        <ng-container *ngFor="let day of week">
          <div class="week-date disabled" *ngIf="!isSelectedMonth(day.mDate)">
            <span class="date-text">{{ day.mDate.date() }}</span>
          </div>
          <div class="week-date enabled"
               *ngIf="isSelectedMonth(day.mDate)"
               (click)="selectDate(day)"
               [ngClass]="{ today: day.today, selected: day.selected }">
            <span class="date-text">{{ day.mDate.date() }}</span>
          </div>
        </ng-container>
      </div>
    </div>
  </div>
</div>

Setup SCSS

I'm not going to go hardcore into the css here, but if you'd like to copy it, go for it. Otherwise come up with your own style.

@import '../../styles/vars.scss';
@import '../../styles/typography.scss';
@import '../../styles/colors.scss';
$dayBase: 30px;

.calendar { display: block; width: $dayBase * 7; margin: 0 auto;

  • { box-sizing: border-box; }

.calendar-navs { background-color: $cloud; }

.month-nav { padding: $base; display: flex; flex-direction: row; justify-content: space-between; }

.year-nav { padding: $base; display: flex; flex-direction: row; justify-content: space-between; font-family: 'Montserrat'; }

.month-grid { .day-names { display: flex; flex-direction: row; background: $concrete; border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; } .weeks { display: flex; flex-direction: column; } .week { display: flex; flex-direction: row; } .week-date, .day-name { text-align: center; padding: $base; display: block; width: $dayBase; display: flex; justify-content: center; align-items: center; }

.week-date {
  height: $dayBase;
  position: relative;

  .date-text {
    z-index: 10;
    font-size: 10px;
    font-family: &#39;Montserrat&#39;, sans-serif;
  }

  &amp;::after {
    content: &#39;&#39;;
    height: $dayBase * 0.9;
    width: $dayBase * 0.9;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    transition: background-color 150ms linear, color 150ms linear;
    z-index: 1;
  }

  &amp;.enabled {
    cursor: pointer;
    &amp;:hover {
      &amp;:after {
        background-color: $seafoam;
      }
    }
  }
  &amp;.selected {
    color: $white;
    &amp;:after {
      background-color: $teal;
    }
    &amp;:hover {
      &amp;:after {
        background-color: $teal;
      }
    }
  }

  &amp;.disabled {
    color: $light-blue-grey;
  }
}

.today {
  font-weight: bold;
}

} }

In case you missed the link at the beginning, check out the full source code with specs here .