Bootstrap 4 Responsive Calendar as an ASP.NET Core TagHelper

I recently needed a nice-looking month-view calendar that could display color-coded events. After searching around the Interwebs, I found a few options, but nothing that quite met my needs. I've been starting to use Bootstrap 4 more lately, and realized that its flexbox grid system could really help in creating a calendar like this that would actually be responsive. Since Bootstrap 4 is still so new, it seems no one else had already created something like this, so I set out to make it myself. The code is publicly available on my CodePen account, so feel free to use it or fork it.

See the Pen Bootstrap 4 Responsive Calendar by Chris Pratt (@chrisdpratt) on CodePen.

I'm also working pretty extensively with ASP.NET Core, now, and this seemed like a perfect candidate for a TagHelper. If you look at the pen, you can see there's quite a bit of code for creating the calendar, so how nice would it be to just do something like: <calendar /> and have all that dumped on the page automatically. Yeah, real nice. So here's the TagHelper I created for that:

[HtmlTargetElement("calendar", TagStructure = TagStructure.NormalOrSelfClosing)]
public class CalendarTagHelper : TagHelper  
{
    public int Month { get; set; }

    public int Year { get; set; }

    public List<CalendarEvent> Events { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = "section";
        output.Attributes.Add("class", "calendar");
        output.Content.SetHtmlContent(GetHtml());
        output.TagMode = TagMode.StartTagAndEndTag;
    }

    private string GetHtml()
    {
        var monthStart = new DateTime(Year, Month, 1);
        var events = Events?.GroupBy(e => e.Date);

        var html = new XDocument(
            new XElement("div",
                new XAttribute("class", "container-fluid"),
                new XElement("header",
                    new XElement("h4",
                        new XAttribute("class", "display-4 mb-2 text-center"),
                        monthStart.ToString("MMMM yyyy")
                    ),
                    new XElement("div",
                        new XAttribute("class", "row d-none d-lg-flex p-1 bg-dark text-white"),
                        Enum.GetValues(typeof(DayOfWeek)).Cast<DayOfWeek>().Select(d =>
                            new XElement("h5",
                                new XAttribute("class", "col-lg p-1 text-center"),
                                d.ToString()
                            )
                        )
                    )
                ),
                new XElement("div",
                    new XAttribute("class", "row border border-right-0 border-bottom-0"),
                    GetDatesHtml()
                )
            )
        );

        return html.ToString();

        IEnumerable<XElement> GetDatesHtml()
        {
            var startDate = monthStart.AddDays(-(int)monthStart.DayOfWeek);
            var dates = Enumerable.Range(0, 42).Select(i => startDate.AddDays(i));

            foreach (var d in dates)
            {
                if (d.DayOfWeek == DayOfWeek.Sunday && d != startDate)
                {
                    yield return new XElement("div",
                        new XAttribute("class", "w-100"),
                        String.Empty
                    );
                }

                var mutedClasses = "d-none d-lg-inline-block bg-light text-muted";
                yield return new XElement("div",
                    new XAttribute("class", $"day col-lg p-2 border border-left-0 border-top-0 text-truncate {(d.Month != monthStart.Month ? mutedClasses : null)}"),
                    new XElement("h5",
                        new XAttribute("class", "row align-items-center"),
                        new XElement("span",
                            new XAttribute("class", "date col-1"),
                            d.Day
                        ),
                        new XElement("small",
                            new XAttribute("class", "col d-lg-none text-center text-muted"),
                            d.DayOfWeek.ToString()
                        ),
                        new XElement("span",
                            new XAttribute("class", "col-1"),
                            String.Empty
                        )
                    ),
                    GetEventHtml(d)
                );
            }
        }

        IEnumerable<XElement> GetEventHtml(DateTime d)
        {
            return events?.SingleOrDefault(e => e.Key == d)?.Select(e =>
                new XElement("a",
                    new XAttribute("class", $"event d-block p-1 pl-2 pr-2 mb-1 rounded text-truncate small bg-{e.Type} text-white"),
                    new XAttribute("title", e.Title),
                    e.Title
                )
            ) ?? new[] {
                new XElement("p",
                    new XAttribute("class", "d-lg-none"),
                    "No events"
                )
            };
        }
    }
}

There's quite a bit of code there, and since many of you may be unfamiliar with TagHelpers, let's take a minute or two to walk through it.

The core of a TagHelper is simply a class that inherits from TagHelper and overrides either Process or ProcessAsync, depending on whether or not the helper needs to do any async work. Our calendar here just generates some static HTML, so there's no async work to be done. As a result, I've overridden Process. The meat of that is just 4 lines. First, I set the tag to a section to replace the non-HTML compliant custom calendar tag I'm going to use, and add a calendar class to it so I can target it with CSS. Then, the body of the tag is replaced with our calendar HTML. Finally, I set it generate both start and end tags, since I'm going to be making the calendar tag, self-closing, whereas, the section tag will not be.

Just above Process, you'll notice three properties: Month, Year, and Events. Public properties on a TagHelper can be filled via tag attributes, allowing me to effectively pass in this information. The final component is the HtmlTargetElement attribute the class is decorated with. This mostly exists here to allow me to set that the calendar tag can be self-closing. It also sets that the tag will be calendar although convention was enough for that piece. By default, the tag will be the name of the TagHelper, minus the TagHelper bit at the end.

Of course, this doesn't do much without our calendar HTML, which is what the private GetHtml method covers. I'm using XDocument to create the HTML structure, because I thoroughly enjoy it's fluent syntax. To me, the code is much more readable and understandable than a series of string concats or StringBuilder calls. In the end, I need only call ToString() on it to get my nicely formatted HTML.

There's also two local functions in this method, a feature new to C# 7.0. They serve as helpers to generate the HTML for the date boxes on the calendar, and the events, if any, that go inside. This keeps the code much cleaner than trying to do it all in one place and allows me to create the XDocument all in one go, instead of having to select out elements to inject HTML into.

The last piece you need, if you haven't added custom TagHelpers before, is to include them for use in your Razor views. That's achieved by editing the Views/_ViewImports.cshtml file and adding the following line:

@addTagHelper *, Your.Project.Namespace

The * will serve to import any and all TagHelpers throughout the entire project, so you only need to do this once. Finally, we simply (and I do mean simply) add our new calendar tag where we want it to appear:

<calendar month="11" year="2017" events="@events" />

Excuse me for a second while I marvel once more that that line. I'm sorry, but that's just beyond amazing. The month and year attributes are obvious. The events attribute should be fed a List<CalendarEvent>. CalendarEvent, for my purposes, is just:

public class CalendarEvent  
{
    public string Title { get; set; }
    public DateTime Date { get; set; }
    public string Type { get; set; }
}

Title is just the name of the event, and Date is the date of the event, which is used for grouping and selecting events that should be shown on each date as the calendar is being rendered. As a quick-and-dirty implementation of color-coding, Type holds one of the Bootstrap color-style names: info, success, warning, danger, etc. A real world scenario would likely require a more advanced implementation, but this was good enough for now.

There you have it: a nice responsive calendar with color-coded events, easily embed-able on a page via a simple tag.

One more thing…

The CodePen version of the calendar is designed for a full-page view. Once you need to actually embed this calendar into an existing page with other HTML, you'll likely need to make some modifications to the CSS. The CSS I'm actually using for the TagHelper version of this is:

.calendar .display-4 {
    font-size: 1.5rem;
}

@media (max-width:991px) {
    .calendar .day h5 {
        background-color: #f8f9fa;
        padding: 3px 5px 5px;
        margin: -8px -8px 8px -8px;
    }

    .calendar .date {
        padding-left: 4px;
    }
}

@media (min-width: 992px) {
    .calendar h5 {
        font-size: 0.9rem;
    }

    .calendar .day {
        height: 8vw;
    }

    .calendar .event {
        font-size: 0.67rem;
    }
}

@media (min-width: 1440px) {
    .calendar .day {
        height: 7vw;
    }
}

@media (min-width: 1920px) {
    .calendar .day {
        height: 5.5vw;
    }
}

Mostly, all that is just some responsive media queries to fudge with the height of the calendar boxes to make them more or less "square" at different resolutions. I've also fiddled with the font-sizing a bit to make better use of a more constricted space. You may or may not need to customize the CSS based on your needs, but just be aware that it is easily tweakable. Thanks to the powerful classes Bootstrap 4 now provides, the vast majority of the style is just built-in, which saves both time, typing, and Excedrin.

Also, note that in the TagHelper code I switch to the lg grid option in places where the CodePen code uses the sm grid option. This is simply due to that fact that it becomes more appropriate to switch to the flat event list at a larger screen resolution when you only have part of that screen resolution available to the control. In the fullscreen version, there's more play room, so we don't have to switch views until we're at a very small resolution. Again, this is something you'll likely have to tweak for your purposes.

comments powered by Disqus