Click here to Skip to main content
15,886,724 members
Articles / Web Development / HTML

What Did I Do with this Page?

Rate me:
Please Sign up or sign in to vote.
4.11/5 (4 votes)
11 Apr 2019CPOL4 min read 8.5K   5   1
It involves implementation of multiple jQuery autocomplete widgets and multiple same data model form partials on a .NET web page with consideration of accessibility.

Introduction

Following instructions to implement jQuery autocomplete widget to an ASP.NET page is not difficult. However, when there are multiple autocomplete widgets added to a page by using a partial, some extra work is required. It's the case also when web accessibility is a must.

Background

It is an ASP.NET Core MVC Razor view page.

Originally, four text input boxes were converted into select list boxes by Selectize. Two of them are for Managers, for which Selectized box is OK, since the number of items are not big. The other two are for employees which are of numbers in hundreds or thousands, and Selectize is not a good player for this.

Developing the Code

Here is the first version of the autocomplete widget as a MVC partial page.

_AutoComplete.cshtml:

HTML
@using System.Web
@{
    Layout = null;
}

@* The reason to change the jquery built-in autocomplete styles is that the page is Bootstrapped *@
<style>
    .ui-autocomplete {
        position: absolute;
        z-index: 1000;
        cursor: pointer;
        padding: 0;
        margin-top: 2px;
        list-style: none;
        background-color: #ffffff;
        border: 1px solid #ccc;
        -webkit-border-radius: 5px;
        -moz-border-radius: 5px;
        border-radius: 5px;
        -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
        -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
        box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
    }

        .ui-autocomplete > li {
            padding: 3px 20px;
        }

            .ui-autocomplete > li.ui-state-focus {
                background-color: #DDD;
            }

    .ui-helper-hidden-accessible {
        display: none;
    }

    .fixed-height {
        padding: 3px 10px;
        max-height: 200px;
        max-width: 18%;
        overflow: auto;
        background-color: #ecf0f1;
        cursor: pointer;
    }

    div.widgetChrome {
        background-color: #f2fbfa;
        padding: 5px;
    }
</style>

<script type="text/javascript">
    var acTarget = $('#' + '@Html.Raw(ViewData["TargetId"])');
    var acTargetHiddenValueElement = $('#' + '@Html.Raw(ViewData["TargetHiddenValueElementId"])');
    var source = JSON.parse('@Html.Raw
                 (HttpUtility.JavaScriptStringEncode(ViewData["Source"]?.ToString()))');
    var ajaxUrl = '@Html.Raw(ViewData["AjaxUrl"])';
    var valueIs = '@Html.Raw(ViewData["ValueIs"] ?? "Number")';

    if (acTarget.parent().find('img').length == 0) {
        acTarget.parent().append($("<img>",
        {
            src: "/images/waiting-blue.gif",
            style: "margin-left:90%; margin-top:-60px; width:24px; height:24px"
        }));
    }
    acTarget.autocomplete({
        source: (function(){
            if (source) {
                return source;
            } else if (ajaxUrl) {
                return function (request, response) {
                    $.ajax({
                        url: ajaxUrl,
                        type: 'GET',
                        data: request,
                        dataType: "json",

                        success: function (data) {
                            response($.map(data, function (item) {
                                acTargetHiddenValueElement.val('');
                                if (item.Text == 'ERROR') {
                                    return {
                                        label: '',
                                        value: valueIs == 'Number' ? -1 : ''
                                    }
                                } else {
                                    $('.ui-autocomplete ui-menu-item a').css('color', '#1578b1');
                                    /*in case user just types in the name without using 
                                      the drop down list to select.*/
                                    if ($.trim(acTarget.val()).toString().toLowerCase() == 
                                                    $.trim(item.Text).toString().toLowerCase()) {
                                        acTargetHiddenValueElement.val(item.Value);
                                    }
                                    return {
                                        label: item.Text,
                                        value: item.Value
                                    }
                                }
                            }));
                        },

                        error: function (xhr, status, errorThrown) {
                            alert(errorThrown);
                        },

                        complete: function (xhr, status) {
                            acTarget.parent()
                                .find("img")
                                .fadeOut(400, function () {
                                    acTarget.parent().find("img").remove();
                                });
                        }
                    });
                }
            }
        })(),
        select: function(event, ui){
            event.preventDefault();
            if (ui.item.value == -1 || !ui.item.value) {
                return false;
            } else {
                acTargetHiddenValueElement.val(ui.item.value);
                acTarget.val(ui.item.label);
            }
        },
        focus: function(event, ui){
            event.preventDefault();
            acTargetHiddenValueElement.val(ui.item.value);
            acTarget.val(ui.item.label);
        },
        open: function(){
            $(this).autocomplete("widget").css({
                "width": ($(this).width() + "px")
            });
        }
    }).autocomplete("widget").addClass("fixed-height");
</script>

In the host page, the partial can be called like this:

C#
<label asp-for="ManagerEmail"></label>
<input type="email" class="form-control" 
asp-for="ManagerEmail" placeholder="Start typing an email...">
<input type="hidden" asp-for="ManagerContactId" />
@await Html.PartialAsync("~/Views/Partials/_AutoCompletion.cshtml", new ViewDataDictionary(ViewData) {
   { "TargetId", "ManagerEmail" },
   { "TargetHiddenValueElementId", "ManagerContactId" },
   { "Source", JsonConvert.SerializeObject(Model.Contacts.Select
                             (x => new { value = x.Id, label = x.Name })) },
   // Or replace "Source" with 
   // { "AjaxUrl", Url.Action("GetByEmail", "Contact") }, 
   // for ajax call when data is bigger.
   { "ValueType", "String" }
})

If source data is passed in to the partial by the ViewData, the partial will use the browser local data. If not, but with an AjaxUrl, an Ajax call will be triggered to pull data from a controller action or a web service. So it works fine with both local and remote data.

But there is an issue with the menu options if the z-index value is not big enough. In my case, one of the widgets is used within a bootstrap modal dialog. Here is the screen:

Image 1

To fix this issue, we should set a big number to its z-index. it should be bigger than the z-index of the dialog. There is another discussion about this later on. This is one reason that we should duplicate the styles here required for this widget by jQuery UI.

Another reason is, since we use other libraries such as Bootstrap and jqGrid, there are chances that the required CSS might be overridden so that the widget might not have a look and feel as expected.

This partial works if there is only one autocomplete widget. As mentioned in the above, there are 4 in total. To my surprise, once I applied all the widgets, when I typed in one widget, the menu appeared below another one. Upon investigation, I figured out a fix. Here below is the second version, which looks like an encapsulated partial so that multiple instances won't cause conflicts.

_AutoComplete.cshtml Ver. 2:

HTML
@using System.Web
@{
    Layout = null;
}

@{
    @* The reason to change the jquery built-in autocomplete styles is that the page is Bootstrapped *@
    var styles = @"
<style>
.ui-autocomplete {
position: absolute;
top: 0;
left: 0;
z-index: {{maxZ}} !important;
/* The rest is same with Ver. 1 */
}
</style>";
}
<script type="text/javascript">
    (function(acTarget, acTargetHiddenValueElement, acSource, acAjaxUrl, acDataValueType) {
        var styleAdded = false;
        $.each($('head style'), function () {
            if (this.innerText.indexOf('.ui-autocomplete') > -1) {
                styleAdded = true;
                return false;
            }
        });
        if (!styleAdded) {
            // this comes from https://blog.csdn.net/butterfly5211314/article/details/79488229
            var maxZ = Math.max.apply(null, $.map($('body *'), function (e, n) {
                if ($(e).css('position') != 'static') return parseInt($(e).css('z-index')) || -1;
            }));
            $('head').append('@Html.Raw(styles.Replace(Environment.NewLine, ""))'.replace
                            ('{{maxZ}}', maxZ));
        }

        acTarget.autocomplete({
            // all the event handlers are the same as in version 1. omitted for brevity.
        });
    })(
        $('#' + '@Html.Raw(ViewData["TargetId"])'),
        $('#' + '@Html.Raw(ViewData["TargetHiddenValueElementId"])'),
        '@ViewData["Source"]' ? JSON.parse('@Html.Raw(HttpUtility.JavaScriptStringEncode
                                          (ViewData["Source"]?.ToString()))') : '',
        '@Html.Raw(ViewData["AjaxUrl"])',
        '@Html.Raw(ViewData["ValueType"] ?? "Number")'
    );
</script>

This version works with any number of the widgets on the same page. Also, there are two points that are worth mentioning:

  1. The style required by the widget is added only once without duplication.
  2. The z-index value could be hard coded with a very big number like 999999. However, I like to use the calculation as listed.

Accessibility Issue

Image 2 I use WAVE plugin to evaluate the web accessibility. Astonishingly there are multiple form label errors, it seems unbelievable at first glance.

Why? It is because of the multiple instances of the partial (not the autocomplete widget partial, but Contact partial) rendered with same models. For instance, to add and update manager contact, two rendered partials consume the same manager contact model. It is the same thing with the employee contact partials.

To address this issue, we need distinguish the form label's for attribute and field id value between the same models bound/rendered on the page.

Intuitively, one may tend to write JavaScript code to change the labels for attribute and the corresponding form control id. It should work, but I did not try this approach, since we can make use of ASP.NET Core MVC's powerful tag helpers to write cleaner and more efficient codes.

Here is how I did it.

Tag helpers: Label tag helper and Input tag helper.

C#
[HtmlTargetElement("label", Attributes = "asp-for")]
public class ResolveMultipleAriaLabelsLabelTagHelper : LabelTagHelper
{
    public ResolveMultipleAriaLabelsLabelTagHelper(IHtmlGenerator generator) : base(generator)
    {
    }

    public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        var viewData = ViewContext.ViewData;
        if (viewData["IdPrefixForMultipleAriaLabels"] != null)
        {
            var forAttribute = output.Attributes.FirstOrDefault
                               (attribute => attribute.Name.ToLower() == "for");
            if (forAttribute != null)
            {
                CreateOrMergeAttribute(string.Format("{0}_{1}", 
                     viewData["IdPrefixForMultipleAriaLabels"], forAttribute.Value), output);
            }
        }
        var hiddenAriaAttr = output.Attributes.FirstOrDefault
                      (attribute => attribute.Name.ToLower() == "aria-hidden");
        if (hiddenAriaAttr != null && hiddenAriaAttr.Value.ToString() == "true")
        {
            output.TagName = "div";
            #region keep the look and feel by design as much as possible
            output.Attributes.Add("class", "label-p");
            output.Attributes.Add("style", "cursor:pointer");
            #endregion
        }

        return base.ProcessAsync(context, output);
    }
    
    private void CreateOrMergeAttribute(string forName, TagHelperOutput output)
    {
        if (string.IsNullOrEmpty(forName)) return;
        var attribute = new TagHelperAttribute("for", forName);
        output.Attributes.SetAttribute(attribute);
    }
}
C#
[HtmlTargetElement("input", Attributes = "asp-for")]
public class ResolveMultipleAriaLabelsInputTagHelper : InputTagHelper
{
    public ResolveMultipleAriaLabelsInputTagHelper(IHtmlGenerator generator) : base(generator)
    {
    }

    public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        var viewData = ViewContext.ViewData;
        if (viewData["IdPrefixForMultipleAriaLabels"] != null)
        {
            var nameAttribute = output.Attributes.FirstOrDefault
                  (attribute => attribute.Name.ToLower() == "name");
            CreateOrMergeAttribute(string.Format("{0}_{1}", 
                  viewData["IdPrefixForMultipleAriaLabels"], nameAttribute.Value), output);
        }
        return base.ProcessAsync(context, output);
    }

    private void CreateOrMergeAttribute(string id, TagHelperOutput output)
    {
        if (string.IsNullOrEmpty(id)) return;
        var attribute = new TagHelperAttribute("Id", id);
        output.Attributes.SetAttribute(attribute);
    }
} 

The idea behind is to change the label's for attribute and the input id if necessary, so that they can match one to one.

If we want it rendered with distinguished label-for and input-id, call the partial this way:

C#
@await Html.PartialAsync("~/Views/Partials/_AddContact.cshtml", 
        new Contact { CanAssociateAccount = false }, new ViewDataDictionary(ViewData) 
        { { "IdPrefixForMultipleAriaLabels", "Manager" } })

The IdPrefixForMultipleAriaLabels variable is passed into the tag helpers by ViewData carried over in the ViewContext. Then the Contact fields can be rendered into something like this:

HTML
<label for="Manager_FirstName" class="control-label required">First Name</label>
<input class="form-control" type="text" data-val="true" 
 data-val-required="The First Name field is required." 
 id="Manager_FirstName" name="FirstName" value="">
<span class="text-danger field-validation-valid" 
 data-valmsg-for="FirstName" data-valmsg-replace="true"></span>

The label's for attribute then matches solely with the input id prefixed with "Manager_". With this applied, WAVE will never complain about the multiple labels for a single input in the Contact partials. Though assistive tools may not have problems with the multiple labels, the project owner will be happy with the fix.

Points of Interest

  1. Use of JavaScript function to isolate same partials on one page
  2. jQuery autocomplete widget to consume local or remote data, depending on the data size
  3. Use of ASP.NET Core MVC tag helpers to address accessibility issues
  4. User data passed from view page to tag helpers to do conditional work

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
Canada Canada
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
-- No messages could be retrieved (timeout) --