JavaScript - Calendar Date Picker

From NoskeWiki
Jump to navigation Jump to search
Date picker demo (datepickerdemo.html) in action.

About

NOTE: This page is a daughter page of: JavaScript


A common task in online forms is to enter a date (often for DOB, but often for reservations), and to help users select the correct date is to have a calendar popup. I found a really fantastic little datepickr class by Josh Salverda (who I've credited below) and uses fairly minimal code: 1 x .js file and 1 x .css file, with ~ 600 total lines (not bad considering all the necessary logic for calendar days etc). I've taken this code, modified it a little, to conform to Google's JavaScript Style Guide and provided it below as a nice base you can copy and paste, and then customize by playing with the styles etc.


There are several large JavaScript libraries which include date pickers, for example JQuery UI's Datepicker, and there are also many other independent date picker scripts such as those listed here.... but I really like Josh's visual style and so this is the one I chose and I'm sure I'll reuse again! :)


NOTE: In HTML5 they have added a new "date" type for the input tag which adds a date picker for you.... however won't work for older browsers. The one line of code is:

<input type="date" name="my_date" id="my_date">

Date Picker Code

INSTRUCTIONS: Create the following three files in the same dir and you should be good to go!

See more involved demo working here.


datepickerdemo.html:

<!DOCTYPE html>
<html>
  <head>
    <title>Date Picker Demo</title>
    <link rel="stylesheet" href="datepicker.css">
  </head>
  <body>
    <h3>Date Picker:</h3>
    <input id="datepick" size="10" class="datepicker" placeholder="mm/dd/yyyy">
    <script src="datepicker.js"></script>
    <script>
      // Custom date format
      DatePicker('.datepicker', { dateFormat: 'm/d/Y'});
    </script>
  </body>
</html>

datepicker.js:

/**
 * @fileoverview Date picker popup calendar widget.
 *
 * CREDIT:
 *   ~ code based on: datepickr 3.0
 *   ~ authored by:   Josh Salverda <josh.salverda@gmail.com>
 *   ~ available at:  https://github.com/joshsalverda/datepickr
 *   ~ licence:       open (http://www.wtfpl.net/)
 *
 * Code was then adjusted by Andrew Noske (andrew.noske@gmail / anoske@)
 * to conform to Google's JavaScript Style Guide.
 */

/**
 * Apply datepicker popup to all do elements matching the selector.
 * @param {Object} selector Selector for DOM elements to apply to
 *     (eg: '.datepicker' - for all elements matching this class).
 * @param {Object} config Configuration values for this
 *     specific instance (eg: '{ dateFormat: 'm/d/Y'}').
 * @constructor
 */
var DatePicker = function(selector, config) {
  'use strict';
  var elements,
    createInstance,
    instances = [],
    i;

  DatePicker.prototype = DatePicker.init.prototype;

  createInstance = function(element) {
    if (element._DatePicker) {
      element._DatePicker.destroy();
    }
    element._DatePicker = new DatePicker.init(element, config);
    return element._DatePicker;
  };

  if (selector.nodeName) {
    return createInstance(selector);
  }

  elements = DatePicker.prototype.querySelectorAll(selector);

  if (elements.length === 1) {
    return createInstance(elements[0]);
  }

  for (i = 0; i < elements.length; i++) {
    instances.push(createInstance(elements[i]));
  }
  return instances;
};

/**
 * Apply datepicker popup to a particular element.
 * @param {Object} element DOM element on which this operates.
 * @param {Object} instanceConfig Configuration values for this
 *     specific instance (eg: '{ dateFormat: 'm/d/Y'}').
 * @constructor
 */
DatePicker.init = function(element, instanceConfig) {
  'use strict';
  var self = this,
    defaultConfig = {
      dateFormat: 'F j, Y',
      altFormat: null,
      altInput: null,
      minDate: null,
      maxDate: null,
      shorthandCurrentMonth: false
    },
    calendarContainer = document.createElement('div'),
    navigationCurrentMonth = document.createElement('span'),
    calendar = document.createElement('table'),
    calendarBody = document.createElement('tbody'),
    wrapperElement,
    currentDate = new Date(),
    wrap,
    date,
    formatDate,
    monthToStr,
    isSpecificDay,
    buildWeekdays,
    buildDays,
    updateNavigationCurrentMonth,
    buildMonthNavigation,
    handleYearChange,
    documentClick,
    calendarClick,
    buildCalendar,
    getOpenEvent,
    bind,
    open,
    close,
    destroy,
    init;

  calendarContainer.className = 'datepicker-calendar';
  navigationCurrentMonth.className = 'datepicker-current-month';
  instanceConfig = instanceConfig || {};

  wrap = function() {
    wrapperElement = document.createElement('div');
    wrapperElement.className = 'datepicker-wrapper';
    self.element.parentNode.insertBefore(wrapperElement, self.element);
    wrapperElement.appendChild(self.element);
  };

  date = {
    current: {
      year: function() {
        return currentDate.getFullYear();
      },
      month: {
        integer: function() {
          return currentDate.getMonth();
        },
        string: function(shorthand) {
          var month = currentDate.getMonth();
          return monthToStr(month, shorthand);
        }
      },
      day: function() {
        return currentDate.getDate();
      }
    },
    month: {
      string: function() {
        return monthToStr(self.currentMonthView,
                          self.config.shorthandCurrentMonth);
      },
      numDays: function() {
        // Checks to see if february is a leap year otherwise
        // return the respective # of days.
        return self.currentMonthView === 1 &&
            (((self.currentYearView % 4 === 0) &&
            (self.currentYearView % 100 !== 0)) ||
            (self.currentYearView % 400 === 0)) ?
            29 : self.l10n.daysInMonth[self.currentMonthView];
      }
    }
  };

  formatDate = function(dateFormat, milliseconds) {
    var formattedDate = '',
      dateObj = new Date(milliseconds),
      formats = {
        d: function() {
          var day = formats.j();
          return (day < 10) ? '0' + day : day;
        },
        D: function() {
          return self.l10n.weekdays.shorthand[formats.w()];
        },
        j: function() {
          return dateObj.getDate();
        },
        l: function() {
          return self.l10n.weekdays.longhand[formats.w()];
        },
        w: function() {
          return dateObj.getDay();
        },
        F: function() {
          return monthToStr(formats.n() - 1, false);
        },
        m: function() {
          var month = formats.n();
          return (month < 10) ? '0' + month : month;
        },
        M: function() {
          return monthToStr(formats.n() - 1, true);
        },
        n: function() {
          return dateObj.getMonth() + 1;
        },
        U: function() {
          return dateObj.getTime() / 1000;
        },
        y: function() {
          return String(formats.Y()).substring(2);
        },
        Y: function() {
          return dateObj.getFullYear();
        }
      },
      formatPieces = dateFormat.split('');

    self.forEach(formatPieces, function(formatPiece, index) {
      if (formats[formatPiece] && formatPieces[index - 1] !== '\\') {
        formattedDate += formats[formatPiece]();
      } else {
        if (formatPiece !== '\\') {
          formattedDate += formatPiece;
        }
      }
    });

    return formattedDate;
  };

  monthToStr = function(date, shorthand) {
    if (shorthand === true) {
      return self.l10n.months.shorthand[date];
    }

    return self.l10n.months.longhand[date];
  };

  isSpecificDay = function(day, month, year, comparison) {
    return day === comparison && self.currentMonthView === month &&
        self.currentYearView === year;
  };

  buildWeekdays = function() {
    var weekdayContainer = document.createElement('thead'),
      firstDayOfWeek = self.l10n.firstDayOfWeek,
      weekdays = self.l10n.weekdays.shorthand;

    if (firstDayOfWeek > 0 && firstDayOfWeek < weekdays.length) {
      weekdays = [].concat(weekdays.splice(firstDayOfWeek, weekdays.length),
          weekdays.splice(0, firstDayOfWeek));
    }

    weekdayContainer.innerHTML = '<tr><th>' + weekdays.join('</th><th>') +
        '</th></tr>';
    calendar.appendChild(weekdayContainer);
  };

  buildDays = function() {
    var firstOfMonth =
        new Date(self.currentYearView, self.currentMonthView, 1).getDay(),
      numDays = date.month.numDays(),
      calendarFragment = document.createDocumentFragment(),
      row = document.createElement('tr'),
      dayCount,
      dayNumber,
      today = '',
      selected = '',
      disabled = '',
      currentTimestamp;

    // Offset the first day by the specified amount
    firstOfMonth -= self.l10n.firstDayOfWeek;
    if (firstOfMonth < 0) {
      firstOfMonth += 7;
    }

    dayCount = firstOfMonth;
    calendarBody.innerHTML = '';

    // Add spacer to line up the first day of the month correctly
    if (firstOfMonth > 0) {
      row.innerHTML += '<td colspan="' + firstOfMonth + '">&nbsp;</td>';
    }

    // Start at 1 since there is no 0th day
    for (dayNumber = 1; dayNumber <= numDays; dayNumber++) {
      // if we have reached the end of a week, wrap to the next line
      if (dayCount === 7) {
        calendarFragment.appendChild(row);
        row = document.createElement('tr');
        dayCount = 0;
      }

      today = isSpecificDay(date.current.day(), date.current.month.integer(),
                            date.current.year(), dayNumber) ? ' today' : '';
      if (self.selectedDate) {
        selected = isSpecificDay(self.selectedDate.day, self.selectedDate.month,
            self.selectedDate.year, dayNumber) ? ' selected' : '';
      }

      if (self.config.minDate || self.config.maxDate) {
        currentTimestamp = new Date(self.currentYearView, self.currentMonthView,
                                    dayNumber).getTime();
        disabled = '';

        if (self.config.minDate && currentTimestamp < self.config.minDate) {
          disabled = ' disabled';
        }

        if (self.config.maxDate && currentTimestamp > self.config.maxDate) {
          disabled = ' disabled';
        }
      }

      row.innerHTML += '<td class="' + today + selected + disabled +
          '"><span class="datepicker-day">' + dayNumber + '</span></td>';
      dayCount++;
    }

    calendarFragment.appendChild(row);
    calendarBody.appendChild(calendarFragment);
  };

  updateNavigationCurrentMonth = function() {
    navigationCurrentMonth.innerHTML = date.month.string() + ' ' +
                                       self.currentYearView;
  };

  buildMonthNavigation = function() {
    var months = document.createElement('div'),
      monthNavigation;

    monthNavigation = '<span class="datepicker-prev-month">&lt;</span>';
    monthNavigation += '<span class="datepicker-next-month">&gt;</span>';

    months.className = 'datepicker-months';
    months.innerHTML = monthNavigation;

    months.appendChild(navigationCurrentMonth);
    updateNavigationCurrentMonth();
    calendarContainer.appendChild(months);
  };

  handleYearChange = function() {
    if (self.currentMonthView < 0) {
      self.currentYearView--;
      self.currentMonthView = 11;
    }

    if (self.currentMonthView > 11) {
      self.currentYearView++;
      self.currentMonthView = 0;
    }
  };

  documentClick = function(event) {
    var parent;
    if (event.target !== self.element && event.target !== wrapperElement) {
      parent = event.target.parentNode;
      if (parent !== wrapperElement) {
        while (parent !== wrapperElement) {
          parent = parent.parentNode;
          if (parent === null) {
            close();
            break;
          }
        }
      }
    }
  };

  calendarClick = function(event) {
    var target = event.target,
      targetClass = target.className,
      currentTimestamp;

    if (targetClass) {
      if (targetClass === 'datepicker-prev-month' ||
          targetClass === 'datepicker-next-month') {
        if (targetClass === 'datepicker-prev-month') {
          self.currentMonthView--;
        } else {
          self.currentMonthView++;
        }

        handleYearChange();
        updateNavigationCurrentMonth();
        buildDays();
      } else if (targetClass === 'datepicker-day' &&
                 !self.hasClass(target.parentNode, 'disabled')) {
        self.selectedDate = {
          day: parseInt(target.innerHTML, 10),
          month: self.currentMonthView,
          year: self.currentYearView
        };

        currentTimestamp = new Date(self.currentYearView, self.currentMonthView,
            self.selectedDate.day).getTime();

        if (self.config.altInput) {
          if (self.config.altFormat) {
            self.config.altInput.value = formatDate(self.config.altFormat,
                                                    currentTimestamp);
          } else {
            // Not sure why someone would want to do this... but just in case.
            self.config.altInput.value = formatDate(self.config.dateFormat,
                                                    currentTimestamp);
          }
        }

        self.element.value = formatDate(self.config.dateFormat,
                                        currentTimestamp);

        close();
        buildDays();
      }
    }
  };

  buildCalendar = function() {
    buildMonthNavigation();
    buildWeekdays();
    buildDays();

    calendar.appendChild(calendarBody);
    calendarContainer.appendChild(calendar);

    wrapperElement.appendChild(calendarContainer);
  };

  getOpenEvent = function() {
    if (self.element.nodeName === 'INPUT') {
      return 'focus';
    }
    return 'click';
  };

  bind = function() {
    self.addEventListener(self.element, getOpenEvent(), open);
    self.addEventListener(calendarContainer, 'click', calendarClick);
  };

  open = function() {
    self.addEventListener(document, 'click', documentClick);
    self.addClass(wrapperElement, 'open');
  };

  close = function() {
    self.removeEventListener(document, 'click', documentClick);
    self.removeClass(wrapperElement, 'open');
  };

  destroy = function() {
    var parent,
      element;

    self.removeEventListener(document, 'click', documentClick);
    self.removeEventListener(self.element, getOpenEvent(), open);

    parent = self.element.parentNode;
    parent.removeChild(calendarContainer);
    element = parent.removeChild(self.element);
    parent.parentNode.replaceChild(element, parent);
  };

  init = function() {
    var config,
      parsedDate;

    self.config = {};
    self.destroy = destroy;

    for (config in defaultConfig) {
      self.config[config] = instanceConfig[config] || defaultConfig[config];
    }

    self.element = element;

    if (self.element.value) {
      parsedDate = Date.parse(self.element.value);
    }

    if (parsedDate && !isNaN(parsedDate)) {
      parsedDate = new Date(parsedDate);
      self.selectedDate = {
        day: parsedDate.getDate(),
        month: parsedDate.getMonth(),
        year: parsedDate.getFullYear()
      };
      self.currentYearView = self.selectedDate.year;
      self.currentMonthView = self.selectedDate.month;
      self.currentDayView = self.selectedDate.day;
    } else {
      self.selectedDate = null;
      self.currentYearView = date.current.year();
      self.currentMonthView = date.current.month.integer();
      self.currentDayView = date.current.day();
    }

    wrap();
    buildCalendar();
    bind();
  };

  init();

  return self;
};

DatePicker.init.prototype = {
  hasClass: function(element, className) {
    return element.classList.contains(className);
  },
  addClass: function(element, className) {
    element.classList.add(className);
  },
  removeClass: function(element, className) {
    element.classList.remove(className);
  },
  forEach: function(items, callback) { [].forEach.call(items, callback); },
  querySelectorAll: document.querySelectorAll.bind(document),
  isArray: Array.isArray,
  addEventListener: function(element, type, listener, useCapture) {
    element.addEventListener(type, listener, useCapture);
  },
  removeEventListener: function(element, type, listener, useCapture) {
    element.removeEventListener(type, listener, useCapture);
  },
  l10n: {
    weekdays: {
      shorthand: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
      longhand: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday',
                 'Friday', 'Saturday']
    },
    months: {
      shorthand: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul',
                  'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
      longhand: ['January', 'February', 'March', 'April', 'May', 'June', 'July',
                 'August', 'September', 'October', 'November', 'December']
    },
    daysInMonth: [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
    firstDayOfWeek: 0
  }
};

datepicker.css:

/* Style guide for the datepicker.js objects */

.datepicker-wrapper {
  display: inline-block;
  position: relative;
}

.datepicker-calendar {
  background-color: #eee;
  border: 1px solid #ddd;
  -moz-border-radius: 4px;
  -webkit-border-radius: 4px;
  border-radius: 4px;
  color: #333;
  display: none;
  font-family: 'Trebuchet MS', Tahoma, Verdana, Arial, sans-serif;
  font-size: 12px;
  left: 0;
  padding: 2px;
  position: absolute;
  top: 100%;
  z-index: 100;
}

.open .datepicker-calendar {
  display: block;
}

.datepicker-calendar .datepicker-months {
  background-color: #4d90fe;  /* Kennedy blue. */
  border: 1px solid #2f5bb7;  /* Dark blue. */
  -moz-border-radius: 4px;
  -webkit-border-radius: 4px;
  border-radius: 4px;
  color: #fff;
  font-size: 120%;
  padding: 2px;
  text-align: center;
}

.datepicker-calendar .datepicker-prev-month,
.datepicker-calendar .datepicker-next-month {
  color: #fff;
  cursor: pointer;
  -moz-border-radius: 4px;
  -webkit-border-radius: 4px;
  border-radius: 4px;
  padding: 0 .4em;
  text-decoration: none;
}

.datepicker-calendar .datepicker-prev-month {
  float: left;
}

.datepicker-calendar .datepicker-next-month {
  float: right;
}

.datepicker-calendar .datepicker-current-month {
  padding: 0 .5em;
}

.datepicker-calendar .datepicker-prev-month:hover,
.datepicker-calendar .datepicker-next-month:hover {
  background-color: #fdf5ce;
  color: #c77405;
}

.datepicker-calendar table {
  border-collapse: collapse;
  padding: 0;
  width: 100%;
}

.datepicker-calendar thead {
  font-size: 90%;
}

.datepicker-calendar th,
.datepicker-calendar td {
  width: 14.3%;
}

.datepicker-calendar th {
  text-align: center;
  padding: 5px;
}

.datepicker-calendar td {
  text-align: right;
  padding: 1px;
}

.datepicker-calendar .datepicker-day {
  background-color: #f6f6f6;
  border: 1px solid #ccc;
  color: #1c94c4;
  cursor: pointer;
  display: block;
  padding: 5px;
}

.datepicker-calendar .datepicker-day:hover {
  background-color: #fdf5ce;
  border: 1px solid #fbcb09;
  color: #C77405;
}

.datepicker-calendar .today .datepicker-day {
  background-color: #fff0A5;
  border: 1px solid #fed22f;
  color: #363636;
}

.datepicker-calendar .selected .datepicker-day {
  background-color: #1c94c4;
  color: #f6f6f6;
}

.datepicker-calendar .disabled .datepicker-day,
.datepicker-calendar .disabled .datepicker-day:hover {
  background-color: #eee;
  border: 1px dotted #ccc;
  color: #bbb;
  cursor: default;
}


Acknowledgements: Josh Salverda for writing such a brilliant little class - exactly what I was looking for. :)


See Also