Feeds:
Posts
Comments

This is part two of my custom Date / Time Picker solution. You can see part one here.

In part one, I split up the Date & TimeOfDay properties and placed them into separate text boxes. I then created a custom ASP.NET MVC Model Binder to join the two values back together as a single DateTime when the form is submitted. Now I will focus on the UI.

Attach a jQuery Datepicker to the Date TextBox

In part one, I created an ASP.NET MVC Editor Template to display DateTime variables:

~/Views/Shared/EditorTemplates/DateTime.ascx

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<System.DateTime>" %>
<%: Html.TextBox("", Model.ToShortDateString(), new { @class = "datepicker", @maxlength = "10" })%>

The DateTime Editor Template automatically applies the “.datepicker” CSS class to the TextBox. All we need to do is link the jQuery UI library (containing the Datepicker widget) to Site.master and then call .datepicker() on any TextBoxes referencing the “.datepicker” CSS class.

Site.Master (excerpt)

<link href="../../Content/ui-lightness/jquery-ui-1.8.5.custom.css" rel="stylesheet" type="text/css" />
<script src="../../Scripts/jquery-1.4.2.min.js" type="text/javascript"></script>
<script src="../../Scripts/jquery-ui-1.8.5.custom.min.js" type="text/javascript"></script>
<script src="../../Scripts/Site.js" type="text/javascript"></script>

Site.js

$(document).ready(function () {
    $(".datepicker").datepicker({
        showAnim: '',
        dateFormat: 'm/d/yy',
        showOn: 'button',
        buttonImageOnly: true,
        buttonImage: '../Scripts/txtdropdown/txtdropdown-btn.png',
        buttonText: 'Select a date'
    });
});

That was easy…

image

Attach a jQuery Timedropdown to the Time TextBox

My goal was to create a similar behavior to the time dropdown in Microsoft Outlook:

image

In Outlook, you can either edit the time directly in the textbox or you can click the dropdown and select a time. I tried a few different methods to mimic this behavior, like positioning a dropdown directly over a textbox, etc. The approach that works best for me is creating a hidden unnumbered list (<ul>) directly below the textbox, and a trigger button to show/hide the list.

image

Not bad, right? The code is separated into two functions:

  1. timedropdown() – Appends a <ul> to the textbox containing the time values, and calls txtdropdown().
  2. txtdropdown() – Adds the trigger button, finds the first <ul> after the textbox, formats it, and hides it.

I also used Ariel Flesler’s scrollTo() plugin to make the dropdown scroll to the correct time when shown.

timedropdown.js

(function ($) {
    $.fn.timedropdown = function () {
        return this.each(function () {
            var txt = $(this);
            with (txt) {
                // Create a ul after the textbox containing the time values
                // for the dropdown (this list could be made dynamic)
                parent().append('<ul><li>12:00 AM</li><li>12:30 AM</li><li>1:00 AM</li><li>1:30 AM</li></ul>');

                // Build the dropdown, attach a callback
                txtdropdown(timedropdown_shown);
            }
        });
    };
})(jQuery);

// Callback function to auto-scroll the dropdown to the nearest time
function timedropdown_shown(txt, ddl) {
    // If unable to parse time, default position is...
    var timeIndex = 0;

    // Parse the time value in the textbox
    var time = new Date('1/1/2010 ' + txt.val());

    if (!isNaN(time)) {
        // Determine the index of the li with the nearest time (round down)
        // We assume the times are static, every half-hour, starting with 12:00 AM
        timeIndex = (time.getHours() * 2) + (time.getMinutes() / 30);
    }

    // Select the li at the matching index and scroll to it
    ddl.scrollTo(ddl.children('li').eq(timeIndex));
}

txtdropdown.js

(function ($) {
    $.fn.txtdropdown = function (onshown) {
        return this.each(function () {
            var txt = $(this);

            // The first ul after the textbox becomes the dropdown
            var ddl = txt.next('ul:first');

            with (ddl) {
                // Apply CSS
                addClass('txtdropdown-ddl');

                // Position the dropdown directly below the textbox
                css('position', 'absolute');
                css('width', txt.width() - 2);
                css('left', txt.position().left);
                css('top', txt.position().top + txt.height() + 4);

                // Hide the dropdown
                hide();
                css('visibility', 'visible');

                // Prevent the dropdown from auto-hiding when clicking the scroll buttons
                click(function (e) {
                    e.stopPropagation();
                });

                // Clicking an li sets the textbox val and hides the dropdown
                find('li').click(function () {
                    txt.val($(this).html()).select().focus().change();
                    txtdropdown_hide();
                });
            }

            with (txt) {
                // Surround the textbox with an outer div
                var outer = wrap('<div class="txtdropdown-outer" style="width: ' + width() + 'px"></div>').parent();

                // Create a "button" div inside the outer div
                var btn = outer.prepend('<div class="txtdropdown-btn">&nbsp;</div>').find('.txtdropdown-btn');
                btn.click(function () {
                    with (ddl) {
                        if (is(':visible')) {
                            txtdropdown_hide();
                        }
                        else {
                            txtdropdown_hide();
                            show();
                            if (onshown != null) onshown(txt, ddl);
                        }
                    }
                });

                // Make the textbox width smaller to make room for the button
                width(width() - btn.width() - 4);
                css('border-right', '0');

                // Keypress in the textbox hides the dropdown
                keypress(function () {
                    txtdropdown_hide();
                });

                // Change in the textbox hides the dropdown
                change(function () {
                    txtdropdown_hide();
                });

                // Prevent the dropdown from auto-hiding when clicking the textbox
                outer.click(function (e) {
                    e.stopPropagation();
                });
            }

            // Auto-hide the dropdown(s)
            $(document).click(function () {
                txtdropdown_hide();
            });
        });
    };
})(jQuery);

function txtdropdown_hide() {
    $('.txtdropdown-ddl').hide();
}

I am not posting the CSS here but you can download the project and take a look. I will be adding some client-side validation functions to ensure that the start / end values “follow” each other.

Sometimes you need a date field.
Sometimes you need a time field.
Sometimes you need both.
And you always need client-side “pickers” to make your forms easier to use. 

I recently needed an MVC form for an appointment – with both Start and End DateTime values. 

Here’s the default view scaffolding generated by ASP.NET MVC… 

<div class="editor-label">
    <%: Html.LabelFor(model => model.Start)%>
</div>
<div class="editor-field">
    <%: Html.TextBoxFor(model => model.Start)%>
    <%: Html.ValidationMessageFor(model => model.Start)%>
</div>
<div class="editor-label">
    <%: Html.LabelFor(model => model.End)%>
</div>
<div class="editor-field">
    <%: Html.TextBoxFor(model => model.End)%>
    <%: Html.ValidationMessageFor(model => model.End)%>
</div>

…which ends up looking like this… 

image

Yuck! Big fat text boxes with both the date and time values? I quickly began searching the Net for date & time pickers. The date picker turned out to be pretty easy – both ASP.NET AJAX and jQuery have easy-to-use date pickers. But once I threw the time component into the mix, I felt like I had entered the Bizarro World. Most free time pickers either don’t work, or they are so funky an ordinary user would not find them easy to use (I’m talking about you timepickr!). So, I decided to piece together a few of the good bits I found and come up with a simple, functional date / time picker. 

Separate the Date / Time fields

Credit to Scott Hanselman for showing how to do this on his blog. I took a slightly different approach by using a TimeSpan for the time component and using custom Editor Templates for DateTime and TimeSpan types. 

~/Views/Shared/EditorTemplates/DateTime.ascx

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<System.DateTime>" %>
<%: Html.TextBox("", Model.ToShortDateString(), new { @class = "datepicker", @maxlength = "10" })%>

~/Views/Shared/EditorTemplates/TimeSpan.ascx

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<System.TimeSpan>" %>
<%: Html.TextBox("", new DateTime(Model.Ticks).ToString("h:mm tt"), new { @class = "timedropdown", @maxlength = "8" })%>

Now that we have defined custom Editor Templates for DateTime and TimeSpan types, we will update the view to use them for the Date & Time values…

<div class="editor-label">
    <%: Html.LabelFor(model => model.Start)%>
</div>
<div class="editor-field">
    <div class="date-container">
        <%: Html.EditorFor(model => model.Start.Date)%>
    </div>
    <div class="time-container">
        <%: Html.EditorFor(model => model.Start.TimeOfDay)%>
    </div>
    <div class="clear">
        <%: Html.ValidationMessageFor(model => model.Start)%>
    </div>
</div>

<div class="editor-label">
    <%: Html.LabelFor(model => model.End)%>
</div>
<div class="editor-field">
    <div class="date-container">
        <%: Html.EditorFor(model => model.End.Date)%>
    </div>
    <div class="time-container">
        <%: Html.EditorFor(model => model.End.TimeOfDay)%>
    </div>
    <div class="clear">
        <%: Html.ValidationMessageFor(model => model.End)%>
    </div>
</div>

…here is the result… 

image 

That takes care of rendering the view using separate text boxes, but try submitting the form and you will see we have a problem with model binding… 

image 

You can see in the view that I used the .Date & .TimeOfDay properties of my DateTime values. The default Model Binder ignores those fields and looks instead for fields called “Start” and “End” – which don’t exist. We need a custom Model Binder that is smart enough to take the Start.Date and Start.TimeOfDay values and combine them into a single DateTime.

public class DateTimeModelBinder : DefaultModelBinder
{
    private Nullable<T> GetA<T>(ModelBindingContext bindingContext, string key) where T : struct
    {
        if (bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName))
        {
            if (string.IsNullOrEmpty(key)) key = bindingContext.ModelName;
            else key = bindingContext.ModelName + "." + key;
        }
        if (string.IsNullOrEmpty(key)) return null;

        ValueProviderResult value;
           
        value = bindingContext.ValueProvider.GetValue(key);
        bindingContext.ModelState.SetModelValue(key, value);

        if (value == null)
        {
            return null;
        }

        Nullable<T> retVal = null;
        try
        {
            retVal = (Nullable<T>)value.ConvertTo(typeof(T));
        }
        catch (Exception) { }

        return retVal;
    }
    public override object BindModel(
        ControllerContext controllerContext,
        ModelBindingContext bindingContext)
    {
        if (bindingContext == null) throw new ArgumentNullException("bindingContext");

        // Check for a simple DateTime value with no suffix
        DateTime? dateTimeAttempt = GetA<DateTime>(bindingContext, "");
        if (dateTimeAttempt != null)
        {
            return dateTimeAttempt.Value;
        }

        // Check for separate Date / Time fields
        DateTime? dateAttempt = GetA<DateTime>(bindingContext, "Date");
        DateTime? timeAttempt = GetA<DateTime>(bindingContext, "TimeOfDay");

        //If we got both parts, assemble them!
        if (dateAttempt != null && timeAttempt != null)
        {
            return new DateTime(dateAttempt.Value.Year,
                dateAttempt.Value.Month,
                dateAttempt.Value.Day,
                timeAttempt.Value.Hour,
                timeAttempt.Value.Minute,
                timeAttempt.Value.Second);
        }

        //Only got one half? Return as much as we have!
        return dateAttempt ?? timeAttempt;
    }
}

Don’t forget to register this Model Binder in the Application_Start() method in Global.asax.

ModelBinders.Binders.Add(typeof(DateTime), new Models.DateTimeModelBinder());

That’s it for part one! Here’s proof that it works! 

image 

In the next post, we will use jQuery to attach a date picker to the date field and I will unveil my custom timedropdown plugin!

Follow

Get every new post delivered to your Inbox.