Johan Broddfelt
/* Comments on code */
Donate $

JavaScript date picker from scratch

I do not consider myself an expert in javascript. I would like to learn more about how functions and objects work, how to use this, self and other references to objects. I also want to be better at handling dates in javascript. But I get the feeling that the best way is to use a library what ever way you try to bend javascript...

I have kept this post waiting for a while, but it is an important part of the framework. Because it is really nice to pick dates from a calendar plug in instead of having to type them by hand. Usually I use jQuery in my framework and the datepicker from the jQueryUI, and you use it like this:

$("#datepicker").datepicker();

But I feel that is to easy for an entire post and you do not learn a lot of javascript by looking at that lines of code. So I decided to try to build a date picker from scratch in javascript. And though I must admit it was a bit tedious at times. I started to build a date picker the knowledge I had, and ended up with a solution containing a lot of holes left open for improvements. But I actually got it to work in the end. Then I spent some time reading about best practises and letting people having a look at my code and I came up with a better solution. But let's start by looking at my first draft.

The html is identical for my both samples and it look like this

<div>
    <h1>Datepicker test</h1>
    A: <input class="datepicker" name="A">
    B: <input class="datepicker" name="B">
</div>

Now over to my first draft of javascript

// main.js
// Calendar popup
var PopupCalendar = function() {
    self = this, // I need this to not loose the scope of this
    this.selectedMonth = null,
    this.init = function(e) {
        var me = e;
        addListener(e, 'click', function(e) {
            self.form = me;
            self.selectedMonth = null;
            self.render(e);
        });
    },
    
    // Change selected stat of the days in the calendar and update the value of the form
    this.updateForm = function(e) {
        // If using a date time form we want to preserve the time set
        var selectedDateArr = self.form.value.split(' ');
        if (selectedDateArr.length > 1) {
            selectedDateArr[0] = this.attributes['data-date'].value;
            self.form.value = selectedDateArr.join(' ');
        } else {
            self.form.value = this.attributes['data-date'].value;
        }
        var dp = document.getElementById('dp_datepicker');
        var dpSelected = dp.getElementsByClassName('selected');
        if (dpSelected.length === 1) {
            removeClass(dpSelected[0], 'selected');
        }
        addClass(this, 'selected');
        self.destroy();
    },
    
    // Add a click event on each day so that it can change selected stat and update the form when clicked
    this.addEventToDays = function() {
        var dayDivs = document.getElementsByClassName('dpDay');
        var i = 0;
        while (i < dayDivs.length) {
            addListener(dayDivs[i], 'click', self.updateForm);
            i++;
        }
    },
    
    // Add a click event that changes month
    this.addEventChangeMonth = function(e) {
        var el = e;
        // Find the next and previous month
        var prev = document.getElementById('dpPrev');
        addListener(prev, 'click', function() {
            var dateArr = self.selectedMonth.split('-');
            if (parseInt(dateArr[1], 10) === 1) { dateArr[1] = 12; dateArr[0]--; }
            else { dateArr[1]--; }
            self.selectedMonth = dateArr.join('-');
            self.render(el);
            self.render(el);
        });

        var next = document.getElementById('dpNext');
        addListener(next, 'click', function() {
            var dateArr = self.selectedMonth.split('-');
            if (parseInt(dateArr[1], 10) === 12) { dateArr[1] = 1; dateArr[0]++; }
            else { dateArr[1]++; }
            self.selectedMonth = dateArr.join('-');
            self.render(el);
            self.render(el);
        });
    },
  
    this.render = function(e) {
        // Get selected Date We also want the picker to work on time fields 0000-00-00 00:00:00
        var selectedDateArr = e.target.value.split(' ');
        var selectedDate = selectedDateArr[0];
        var selectedDateJS = new Date(selectedDate);
        
        // Get current Date
        var d = new Date();
        var currentDate = d.toLocaleDateString('sv-SE');
        
        // Decide which month to show
        var showDate = self.selectedMonth;
        if (showDate === null) {
            showDate = currentDate;
            if (selectedDate !== '' && selectedDate !== '0000-00-00') {
                showDate = selectedDate;
            }
        }
        self.selectedMonth = showDate;
        
        // Build a list of days for the month to show
        // Get the find the first Monday prior to the first day of this month if the first is not a Monday
        var showDateJS = new Date(showDate);
        var firstDay = showDateJS.getFullYear() + '-' + (showDateJS.getMonth()+1) + '-01';
        var firstDayJS = new Date(firstDay);
        var currDay = firstDayJS.getDay(); // 1 is monday
        var startDayJS = firstDayJS;
        if (firstDayJS.getDay() !== 1) {
            if (firstDayJS.getDay() === 0) {
                currDay = 7;
            }
            // Calculate days to Monday
            var daysUntilMonday = (-currDay + 1);
            // Find the first Monday to display
            startDayJS.setTime(Date.parse(firstDayJS.toLocaleDateString('sv-SE')) + (daysUntilMonday*24*3600*1000));
        }
        var monthArr = ['Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
        var monthHead = '<div id="dpPrev" class="dpNav"><</div><div id="dpMonth">'
                        + showDateJS.getFullYear() + ' ' + monthArr[showDateJS.getMonth()] 
                        + '</div><div id="dpNext" class="dpNav">></div>';
        
        var dayList = '<div class="dpDayCol">Mo</div>'
                    + '<div class="dpDayCol">Tu</div>'
                    + '<div class="dpDayCol">We</div>'
                    + '<div class="dpDayCol">Th</div>'
                    + '<div class="dpDayCol">Fr</div>'
                    + '<div class="dpDayCol">Sa</div>'
                    + '<div class="dpDayCol">Su</div>'
                    ;
        var hide = false;
        for (var i = 0; i < 42; i++) {
            var currMonthStyle = '';
            var today = '';
            if (startDayJS.getMonth() !== showDateJS.getMonth()) {
                currMonthStyle = 'other_month';
            }
            if (startDayJS.toLocaleDateString() === d.toLocaleDateString()) {
                today = 'today';
            }
            var selected = '';
            if (startDayJS.toLocaleDateString() === selectedDateJS.toLocaleDateString()) {
                selected = 'selected';
            }
            if (currMonthStyle !== '' && i === 35) {
                hide = true;
            }
            if (!hide) {
                dayList += '<div class="dpDay ' + currMonthStyle + ' ' + today + ' ' + selected + '" data-date="' + startDayJS.toLocaleDateString('sv-SE') + '">' + startDayJS.getDate() + '</div>';
            }
            startDayJS.setTime(Date.parse(firstDayJS.toLocaleDateString('sv-SE')) + (1*24*3600*1000));
        }
        // Create the frame structure for our component
        var dc = document.getElementById('dpContainer');
        if (dc === null) {
            var dpString = '<div id="dp_datepicker"><div id="dpHead">' + monthHead + '</div><div id="dpBody">' + dayList + '</div></div>';
            // We can not append a string, so we need to create a container element that we can add
            // If we add a string to the document.body.innerHTML all events will be removed
            var elem = document.createElement("div");
            elem.id = 'dpContainer';
            elem.innerHTML = dpString;
            // Add our component to the DOM
            document.body.appendChild(elem);
            var dp = document.getElementById('dp_datepicker');
            // Get the position
            var pos = getPosition(e.target);
            dp.style.top = (pos.y + 22) + 'px';
            dp.style.left = (pos.x - 2) + 'px';

            // Here this is the html element so I need to use self instead to call my methods
            self.addEventToDays();
            // Add month change trigger
            self.addEventChangeMonth(e);
        } else {
            self.destroy();
        }
    },
    // Remove the calender pop up from the DOM
    this.destroy = function() {
        var dc = document.getElementById('dpContainer');
        document.body.removeChild(dc);
    }
};

// Here is how you implement the plug in
var dp = new PopupCalendar;
var calendarObj = document.getElementsByClassName('datepicker');
var i = 0;
while (i < calendarObj.length) {
    dp.init(calendarObj[i]);
    i++;
}

Demo of the first draft

I have put all my code in a class called PopupCalendar and the first thing I do is to create a new PopupCalendar and then call the applyPopupCalendar(). When a new object is generated I make sure that self is the same as this. So that I can refer to self later on to access the objects functions. I feel this is a bit confusing, but this is the first solution I could find that worked. The applyPopupCalendar() loops through all elements with the class datepicker and adds a click event listener that calls the showPopupCalendar(). The showPopupCalendar() begins by figuring out what dates to show and then it renders or removes the calendar pop up depending on it's current state.

After the pop up has been rendered there are to types of click events added. The first is on each day in the calendar, addEventToDays() that calls updateForm(). updateForm() updates the input element with the selected date and changes the select state on the days in the calendar box. One option her is to just hide the box when done. The second click event is for the calendar navigation, addEventChangeMonth(). This enables us to switch to a previous or an upcoming month, then it re-renders the calendar pop up by calling showPopupCalendar() twice, first to hide it then to show it again. This could easily be improved upon. But it was too easy just to let it be like this for now.

I'm skipping the css in this post because you can take a look at it in my sample code. Instead let's have a look at the second version. First of all I got the tip from Jacob Andresen that I should not refere to existing DOM elements from within my object. On use getElementBy... on elements that has been created by the object it self. Otherwise your plug in is a conflict waiting to happen. The second advice was to create a life cycle for my plug in. Like Init, Render, EventHandling and Destroy. He also advised me to use some external template system like http://handlebarsjs.com/, http://underscorejs.org/#template or the built in templates available when using ES2015. But for now I did not t to use external frameworks or rely on any specific version of javascript being available in the browser so I just created my own simple template function.
Here is what the second version of my datepicker plug in looked like...

// main.js
"use strict";
// Manage classes in html elements
function hasClass(ele,cls) {
    return !!ele.className.match(new RegExp('(s|^)'+cls+'(s|$)'));
}
function addClass(ele,cls) {
    if (!hasClass(ele,cls)) { ele.className += ' ' + cls; }
    
    // Make sure we do not create additional spaces
    var reg = new RegExp('(ss)');
    ele.className=ele.className.replace(reg, ' ');
}
function removeClass(ele,cls) {
    if (hasClass(ele,cls)) {
        var reg = new RegExp('(s|^)'+cls+'(s|$)');
        ele.className=ele.className.replace(reg, ' ');
        
        // Make sure we do not create additional spaces
        reg = new RegExp('(ss)');
        ele.className=ele.className.replace(reg, ' ');
    }
}

// addListener make sure that the listener work in all browsers
var addListener = function(element, eventType, handler, capture) {
    if (capture === undefined) { capture = false; }
    if (element.addEventListener) {
        element.addEventListener(eventType, handler, capture);
    } else if (element.attachEvent) {
        element.attachEvent('on' + eventType, handler);
    }
};

// Get position of the element clicked
var getPosition = function(element) {
    var xP = (element.offsetLeft + element.clientLeft);
    var yP = (element.offsetTop + element.clientTop);
    return {x: xP, y: yP};
};

// In order to better handle date data I want some additional date functions
// I use prototype to add those functions to the javascript default Date object
//     this will make my code a lot cleaner and I can reuse these functions from
//     other classes in my project
Date.prototype.getCurrentDate = function() {
    // Returns the current date in the format yyyy-mm-dd
    var d = new Date();
    return d.toLocaleDateString('sv-SE');
};
Date.prototype.getDatePart = function(dateString) {
    // Get the date part from yyyy-mm-dd HH:mm:ss or just returns the date if no time is provided
    var selectedDateArr = dateString.split(' ');
    return selectedDateArr[0];
};
Date.prototype.getSelectedDate = function(dateString) {
    // Get selected Date We also want the picker to work on time fields 0000-00-00 00:00:00
    var selectedDate = this.getDatePart(dateString);
    return new Date(selectedDate);
};


// Calendar pop up
// This is the object that manages everything regarding the datepicker
// _ in front of a function name indicates that it is regarded as private
var PopupCalendar = {
    // It is always nice to have labels extracted out from the html so that you can easily find and translate them
    dayArr: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],
    monthArr: ['Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
    init: function(e) {
        // Initiate the PopupCalander on an element e
        // If e is not defined there is no element
        if (e === undefined) { return false; }
        
        // Make sure the calendar object is available in the DOM
        this._render(e);
        
        // Add a click event that shows the calendar when the element is clicked
        addListener(e, 'click', function(e) {
            // I'm no longer in scope so I create a new instance to cope :p
            var pc = PopupCalendar;
            pc.form = e;
            pc._activate(e);
        });
    },
    _updateForm: function(e) {
        // Change selected stat of the days in the calendar and update the value of the form
        var currentForm = this.form.srcElement;
        var clickedDay = e.srcElement;
        
        // If using a date time form we want to preserve the time set
        var selectedDateArr = currentForm.value.split(' ');
        if (selectedDateArr.length > 1) {
            selectedDateArr[0] = clickedDay.attributes['data-date'].value;
            currentForm.value = selectedDateArr.join(' ');
        } else {
            currentForm.value = clickedDay.attributes['data-date'].value;
        }
        var dp = document.getElementById('dp_datepicker');
        var dpSelected = dp.getElementsByClassName('selected');
        if (dpSelected.length === 1) {
            removeClass(dpSelected[0], 'selected');
        }
        addClass(clickedDay, 'selected');
        this.hideCalendar();
    },
    _addEventToDays: function(e) {
        // Add a click event on each day so that it can change selected stat and update the form when clicked
        var that = this; // Still need the that solution to stay in scope here! Without this I do not know which form field was used...
        var form = e;
        var dayDivs = document.getElementsByClassName('dpDay');
        var i = 0;
        while (i < dayDivs.length) {
            // WARNING! We have a small memory leak here, because this will keep adding new event listeners every time we click
            //     to avoid it we could do as we have done in _addEventChangeMonth with the data-click attribute
            addListener(dayDivs[i], 'click', function(e) { that._updateForm(e, form); });
            i++;
        }
    },
    _addEventChangeMonth: function(e) {
        // Add a click event that changes month
        // Get the next and previous month DOM elements
        var prev = document.getElementById('dpPrev');
        var next = document.getElementById('dpNext');
        
        // We set the data-click attribute to make sure that we do not get multiple click events attached to the buttons
        // This is a small hack, you can not user removeEventHandler or detachEvent since we do not have the handler stored
        //     those functions need the exact same handler that was used to addEventHandler or attatchEvent
        if (prev.getAttribute('data-click') !== 'on') {
            addListener(prev, 'click', function() {
                var dp = PopupCalendar;
                var monthHead = document.getElementById("dpMonth");
                var month = new Date(monthHead.attributes['data-date'].value);
                month = month.toLocaleDateString('sv-SE');
                var dateArr = month.split('-');
                if (parseInt(dateArr[1], 10) === 1) { dateArr[1] = 12; dateArr[0]--; }
                else { dateArr[1]--; }
                if (parseInt(dateArr[1], 10) < 10) { dateArr[1] = '0' + parseInt(dateArr[1], 10); }
                month = dateArr.join('-');
                monthHead.setAttribute("data-date", month);
                dp._fillData(month, e);
            });

            addListener(next, 'click', function() {
                var dp = PopupCalendar;
                var monthHead = document.getElementById("dpMonth");
                var month = new Date(monthHead.attributes['data-date'].value);
                month = month.toLocaleDateString('sv-SE');
                var dateArr = month.split('-');
                if (parseInt(dateArr[1], 10) === 12) { dateArr[1] = 1; dateArr[0]++; }
                else { dateArr[1]++; }
                if (parseInt(dateArr[1], 10) < 10) { dateArr[1] = '0' + parseInt(dateArr[1], 10); }
                month = dateArr.join('-');
                monthHead.setAttribute("data-date", month);
                dp._fillData(month, e);
            });
        }
        prev.setAttribute('data-click', 'on');
    },
    _getDisplayDate: function(e) {
        var d = new Date();
        // Get selected Date, only the date part if a time stamp is included.
        // Using my prototyped getDatePart function
        var selectedDate = d.getDatePart(e.target.value);
        
        // Get current Date. Using my prototyped getCurrentDate function
        var currentDate = d.getCurrentDate();
        
        // Decide which month to show
        // Show selectedDate if there is one
        var showDate = selectedDate;
        if (selectedDate === '') {
            showDate = currentDate
        }
        return showDate;
    },
    _fillData: function(showDate, e) {
        var d = new Date();
        var selectedDateJS = d.getSelectedDate(e.target.value);
        // Build a list of days for the month to show
        // Get the find the first Monday prior to the first day of this month if the first is not a Monday
        var showDateJS = new Date(showDate);
        var firstDay = showDateJS.getFullYear() + '-' + (showDateJS.getMonth()+1) + '-01';
        var firstDayJS = new Date(firstDay);
        var currDay = firstDayJS.getDay(); // 1 is monday
        var startDayJS = firstDayJS;
        if (firstDayJS.getDay() !== 1) {
            if (firstDayJS.getDay() === 0) {
                currDay = 7;
            }
            // Calculate days to Monday
            var daysUntilMonday = (-currDay + 1);
            // Find the first Monday to display
            startDayJS.setTime(Date.parse(firstDayJS.toLocaleDateString('sv-SE')) + (daysUntilMonday*24*3600*1000));
        }
        var monthHead = document.getElementById("dpMonth");
        var currMonthStyle = false;
        monthHead.innerHTML = showDateJS.getFullYear() + ' ' + this.monthArr[showDateJS.getMonth()];
        monthHead.setAttribute("data-date", showDateJS);
        
        var hide = false;
        for (var i = 0; i < 42; i++) {
            currMonthStyle = true;
            var dpDay = document.getElementById('dpDay_' + i);
            
            removeClass(dpDay, 'other_month');
            if (startDayJS.getMonth() !== showDateJS.getMonth()) {
                addClass(dpDay, 'other_month');
                currMonthStyle = false;
            }
            removeClass(dpDay, 'today');
            if (startDayJS.toLocaleDateString() === d.toLocaleDateString()) {
                addClass(dpDay, 'today');
            }
            removeClass(dpDay, 'selected');
            if (startDayJS.toLocaleDateString() === selectedDateJS.toLocaleDateString()) {
                addClass(dpDay, 'selected');
            }
            if (!currMonthStyle && i === 35) {
                hide = true;
            }
            if (!hide) {
                dpDay.style.display = 'flex-box';
                dpDay.innerHTML = startDayJS.getDate();
                dpDay.setAttribute("data-date", startDayJS.toLocaleDateString('sv-SE'));
                //dayList += '<div class="dpDay ' + currMonthStyle + ' ' + today + ' ' + selected + '" data-date="' + startDayJS.toLocaleDateString('sv-SE') + '">' + startDayJS.getDate() + '</div>';
            } else {
                dpDay.style.display = 'none';
            }
            startDayJS.setTime(Date.parse(firstDayJS.toLocaleDateString('sv-SE')) + (1*24*3600*1000));
        }        
    },
    _activate: function(e) {
        // When a form is clicked this function make sure that the date picker contains the correct data
        //     and is positioned at the right place
        var showDate = this._getDisplayDate(e);
        
        var dc = document.getElementById('dpContainer');
        // Get the position of the form field
        var pos = getPosition(e.target);
        dc.style.top = (pos.y + e.target.offsetHeight) + 'px';
        dc.style.left = (pos.x - 2) + 'px';
        
        // If it is visible we would like to hide it
        // If we click on another picker while it is open we just want to move it
        if (dc.style.display === 'block' && 
            dc.style.top === dc.getAttribute('data-top') &&
            dc.style.left === dc.getAttribute('data-left')
            ) {
            dc.style.display = 'none';
            return false;
        }
        dc.setAttribute('data-top', dc.style.top);
        dc.setAttribute('data-left', dc.style.left);

        this._fillData(showDate, e);
        
        dc.style.display = 'block';

        // Add click events to each day
        this._addEventToDays(e);
        
        // Add month change click events
        this._addEventChangeMonth(e);
    },
    _render: function() {
        var dc = document.getElementById('dpContainer');
        // Add calendar to DOM if it is not already there
        if (dc === null) {
            // We can not append a string, so we need to create an element that we can add
            // We add it as hidden and only show it when we activate it. So that we do not lose any events
            var elem = document.createElement("div");
            elem.style.display = 'none'; // Do not show the date picker at this time.
            elem.style.position = 'absolute'; // Allows us to position the date picker where we want it on the page 
            elem.id = 'dpContainer';
            elem.innerHTML = this._getTemplate();
            
            // Add our component to the DOM
            document.body.appendChild(elem);
        }
        return false;
    },
    hideCalendar: function() {
        // Hide the calender pop up
        // This function does not start with _, that indicates that it can be used from outside.
        //     like dp.hideCalendar(); in this sample where dp is an instance of PopupCalendar
        var dc = document.getElementById('dpContainer');
        dc.style.display = 'none';
    },
    _getTemplate: function() {
        // Build the HTML that displays the content of the pop up calendar
        var monthHead = '<div id="dpPrev" class="dpNav"><</div><div id="dpMonth">'
                        + '</div><div id="dpNext" class="dpNav">></div>';
        var dayList = '';
        for (var i = 0; i < 7; i++) {
            dayList += '<div class="dpDayCol">' + this.dayArr[i] + '</div>';
        }
        for (var i = 0; i < 42; i++) {
            dayList += '<div class="dpDay" id="dpDay_' + i + '" data-date=""></div>';
        }
        var dpString = '<div id="dp_datepicker"><div id="dpHead">' + monthHead + '</div><div id="dpBody">' + dayList + '</div></div>';
        return dpString;
    }
};

// Create a PopupCalendar object and then loop all elements with the class "datepicker" and initiate the PopupCalendar on them
var dp = PopupCalendar;
var calendarObj = document.getElementsByClassName('datepicker');
var i = 0;
while (i < calendarObj.length) {
    dp.init(calendarObj[i]);
    i++;
}

Demo of the second draft

If you have not done it already I urge you to look closely at the code and read the comments. There are some interesting quirks about javascript to be aware of in there. And since I do this to learn from you as well I hope to get lots on feedback on how to do this better. So that I can improve my javascript skills. I am going to do some other minor projects in javascript that could make for some fun useful projects, and I would like them to be written in the best javascript code possible. So please give me some good tips for improvements...

- Framework, JavaScript, DatePicker

<< Modifying the list and edit forms Unit testing the web >>

Comment

Name
Mail (Not public)
Send mail uppdates on new comments

Comments

0 post found