Friday, February 5, 2010

SiteMap Provider based Mega Menu in ASP.NET

Few days back I got a requirement to develop mega menu navigation for a SharePoint application with following requirements.

  1. The design should look like screenshot-1.
  2. The mega menu has one static level and 2 dynamic levels.
  3. It should be based on SharePoint out of box GlobalNavSiteMapProvider. So that the end user can leverage on SharePoint out of box Navigation Settings to edit it's navigation structure.
  4. The dynamic menu items should be arranged from Top to Bottom and then left to right. It means the fly out will have columns of menu items.
  5. The number of items from Top to Bottom should be configurable so that the end user can control the number of columns created in the fly out. More items you have from Top to bottom less will be the no. of columns. The argument for such a design is - the menu should adjust automatically based on the no. of items, a menu control has.




Like any developer, ASP.NET Menu control is the first thing that came to my mind to use for the above requirements. But after spending some time on the ASP.NET Menu control, I found that ASP.NET menu control doesn't support the design (Screenshot-1) we expect. While our requirement was to render the 2nd level dynamic items just under its parent, ASP.NET Menu control renders the 2nd level dynamic items fly out from left to right.

Hence the final decision was to create a Custom control in ASP.NET that will derive its items from any Sitemap Provider and render items as per the above requirements. So the first thing that we searched for - An html drop down menu that contains multiple columns of links. We found jQuery Mega Menu from Javascriptkit.com is the idle for our requirement. This html menu is really awesome. That entire jQuery Mega menu composed of a .css file and a .js file.

The steps to use Jquery Mega menu are as follows.

  1. Download following files from Javascriptkit.com and add them to your head section using link attribute.
  • jkmegamenu.css
  • jkmegamenu.js
  1. Create an anchor link (act as a static link) with id="megaanchor".
  2. Create a DIV for mega drop down menu with id="megamenu1" and class="megamenu"
  3. Initialize the menu by making following JavaScript call

jkmegamenu.definemenu("megaanchor", "megamenu1", "mouseover");

Where as

megaanchor - id of the anchor link

megamenu1 - id of the DIV that represents the mega drop down.

As the static anchor link and its mega drop down menu items are created dynamically in our Custom Control, so we had to generate the ids dynamically and make the JavaScript calls using Page.ClientScript.RegisterClientScriptBlock in OnPreRender method.

As per the demand of our requirement, we exposed following configurable properties.

NumberOfItemsPerColumn - Using this, the end user can specify the no. of items per column in drop down mega menu.

ID - Unique Id of the current instance of the Custom Control.

ProviderName - Name of the Site Map provider that the Custom Control will use to fetch menu items.

DynamicTemplateLevel1 - Template for menu item to be rendered in Dynamic level1.

DynmaicTemplateLevel2 - Template for menu item to be rendered in Dynamic level2.

The complete code of the Custom Control is given below.


/// <summary>

/// This class renders the mega menu from the given provider.
/// </summary>
[ToolboxData("<{0}:MegaMenu runat=server></{0}:MegaMenu>")]
public class MegaMenu : WebControl
{
/// <summary>
/// Maxium Dynamic Level allowed for Mega menu.
/// </summary>
private const int DynamicLevelMax = 2;

/// <summary>
/// Class level variable to track number of links rendered.
/// </summary>
private int itemsInColumnCounter;

/// <summary>
/// Stores dynamic templates as an array.
/// </summary>
private string[] templates;

/// <summary>
/// Initializes a new instance of the <see cref="NewMegaMenu"/> class.
/// </summary>
public MegaMenu()
{
this.NumberOfItemsPerColumn = 1000;
}

/// <summary>
/// Gets the max dynamic level allowed in Mega menu.
/// </summary>
/// <value>The max dynamic level.</value>
[Bindable(true)]
[Category("Appearance")]
[DefaultValue("")]
[Localizable(true)]
public static int MaxDynamicLevel
{
get
{
return DynamicLevelMax;
}

}

/// <summary>
/// Gets or sets the number of items per column.
/// </summary>
/// <value>The number of items per column.</value>
[Bindable(true)]
[Category("Appearance")]
[DefaultValue("0")]
[Localizable(true)]
public int NumberOfItemsPerColumn
{
get;
set;

}

/// <summary>
/// Gets or sets the Mega menu id.
/// </summary>
/// <value>The id that uniquely identify each Mega menu instance.</value>
[Bindable(true)]
[Category("Appearance")]
[DefaultValue("")]
[Localizable(true)]
public string Id
{
get;
set;
}

/// <summary>
/// Gets or sets the Mega menu dynamic level.
/// </summary>
/// <value>The dynamic level.</value>
[Bindable(true)]
[Category("Appearance")]
[DefaultValue("")]
[Localizable(true)]
public int DynamicLevel
{
get;
set;
}

/// <summary>
/// Gets or sets the name of the provider.
/// </summary>
/// <value>The name of the provider.</value>
[Bindable(true)]
[Category("Appearance")]
[DefaultValue("")]
[Localizable(true)]
public string ProviderName
{
get;
set;
}

/// <summary>
/// Gets or sets the dynamic template level1.
/// </summary>
/// <value>The dynamic template level1.</value>
[Bindable(true)]
[Category("Appearance")]
[DefaultValue("")]
[Localizable(true)]
public string DynamicTemplateLevel1
{
get;
set;
}

/// <summary>
/// Gets or sets the dynamic template level2.
/// </summary>
/// <value>The dynamic template level2.</value>
[Bindable(true)]
[Category("Appearance")]
[DefaultValue("")]
[Localizable(true)]
public string DynamicTemplateLevel2
{
get;
set;
}

/// <summary>
/// Raises the <see cref="E:System.Web.UI.Control.PreRender"/> event.
/// </summary>
/// <param name="e">An <see cref="T:System.EventArgs"/> object that contains the event data.</param>
protected override void OnPreRender(EventArgs e)
{
try
{
base.OnPreRender(e);

if (string.IsNullOrEmpty(this.ProviderName))
{
throw new ArgumentNullException(this.ProviderName, "Mega menu parameter 'Provider Name' cannot be null");
}

if (string.IsNullOrEmpty(this.Id))
{
throw new ArgumentNullException(this.Id, "Mega menu Id cannot be null");
}

SiteMapProvider portalSiteMapProvider = SiteMap.Providers[this.ProviderName];
SiteMapNode startNode = portalSiteMapProvider.RootNode;
if (!startNode.HasChildNodes)
{
startNode = portalSiteMapProvider.RootNode;
}

int linkCount = startNode.ChildNodes.Count;

for (int counter = 0; counter < linkCount; counter++)
{

if (startNode.ChildNodes[counter].ChildNodes.Count > 0)
{
if (!this.Page.ClientScript.IsClientScriptBlockRegistered(this.Id + "_megamenu" + counter))
{
string scriptTag = string.Format(
CultureInfo.CurrentCulture,
"<script>jkmegamenu.definemenu('{0}_megaanchor{1}', '{0}_megamenu{1}', 'mouseover')</script>",
this.Id, counter);
this.Page.ClientScript.RegisterClientScriptBlock(
typeof(Page), this.Id + "_megamenu" + counter, scriptTag);

}
}
}
}
catch (Exception exception)
{
exception.LogError();
throw;
}

}

/// <summary>
/// Renders the contents of the mega menu control to the specified writer.
/// </summary>
/// <param name="writer">A <see cref="T:System.Web.UI.HtmlTextWriter"/> that represents the output stream to render HTML content on the client.</param>
protected override void RenderContents(HtmlTextWriter writer)
{
try
{
if (DynamicLevel > MaxDynamicLevel)
{
throw new ArgumentException("Dynamic level of the Mega menu cannot exceed maximum level " + MaxDynamicLevel);
}

SiteMapProvider portalSiteMapProvider = SiteMap.Providers[this.ProviderName];
SiteMapNode startNode = portalSiteMapProvider.RootNode;
if (!startNode.HasChildNodes)
{
startNode = portalSiteMapProvider.RootNode;
}

templates = new string[MaxDynamicLevel];
templates[0] = this.DynamicTemplateLevel1;
templates[1] = this.DynamicTemplateLevel2;

RenderNavigation(startNode, writer);
}
catch (Exception exception)
{
exception.LogError();
throw;
}
}

/// <summary>
/// Renders the navigation.
/// </summary>
/// <param name="startNode">The start node.</param>
/// <param name="output">The output.</param>
private void RenderNavigation(SiteMapNode startNode, HtmlTextWriter output)
{
int counter = 0;

output.AddAttribute(HtmlTextWriterAttribute.Border, "0");
output.AddAttribute(HtmlTextWriterAttribute.Cellspacing, "0");
output.AddAttribute(HtmlTextWriterAttribute.Cellpadding, "0");
output.RenderBeginTag(HtmlTextWriterTag.Table);
output.RenderBeginTag(HtmlTextWriterTag.Tr);

foreach (SiteMapNode node in startNode.ChildNodes)
{
output.RenderBeginTag(HtmlTextWriterTag.Td);

output.AddAttribute(HtmlTextWriterAttribute.Class, "ms-topnav zz1_TopNavigationMenu_4");
output.AddAttribute(HtmlTextWriterAttribute.Border, "1");
output.AddAttribute(HtmlTextWriterAttribute.Cellspacing, "0");
output.AddAttribute(HtmlTextWriterAttribute.Cellpadding, "1");
output.RenderBeginTag(HtmlTextWriterTag.Table);
output.RenderBeginTag(HtmlTextWriterTag.Tr);
output.AddAttribute(HtmlTextWriterAttribute.Style, "white-space:nowrap;");
output.RenderBeginTag(HtmlTextWriterTag.Td);
RenderStaticNavigation(node, output, counter);
counter++;
itemsInColumnCounter = 0;
output.RenderEndTag();
output.RenderEndTag();
output.RenderEndTag();

output.RenderEndTag();

}
output.RenderEndTag();
output.RenderEndTag();

}

/// <summary>
/// Renders the static navigation i.e. the top most menus.
/// </summary>
/// <param name="parentNode">The parent node.</param>
/// <param name="output">The output.</param>
/// <param name="staticMenuCounter">The static menu counter.</param>
private void RenderStaticNavigation(SiteMapNode parentNode, HtmlTextWriter output, int staticMenuCounter)
{
int itemCount = 0;
bool isLastColumnDIVClosed = true;

if (parentNode.ChildNodes.Count > 0)
{
output.AddAttribute(HtmlTextWriterAttribute.Href, parentNode.Url);
output.AddAttribute(HtmlTextWriterAttribute.Id, this.Id + "_megaanchor" + staticMenuCounter);
output.AddAttribute(HtmlTextWriterAttribute.Class, "zz1_TopNavigationMenu_1 ms-topnav zz1_TopNavigationMenu_3");
output.RenderBeginTag(HtmlTextWriterTag.A);
output.Write(parentNode.Title);
output.RenderEndTag();
output.AddAttribute(HtmlTextWriterAttribute.Class, "megamenu");
output.AddAttribute(HtmlTextWriterAttribute.Id, this.Id + "_megamenu" + staticMenuCounter);
output.RenderBeginTag(HtmlTextWriterTag.Div);

output.AddAttribute(HtmlTextWriterAttribute.Class, "column");
output.RenderBeginTag(HtmlTextWriterTag.Div);
isLastColumnDIVClosed = false;

foreach (SiteMapNode node in parentNode.ChildNodes)
{
isLastColumnDIVClosed = RenderDynamicNode(node, output, itemCount, 1, isLastColumnDIVClosed);
itemCount++;
}

if (!isLastColumnDIVClosed)
{
output.RenderEndTag();
isLastColumnDIVClosed = true;
}
output.RenderEndTag();
}
else
{
output.AddAttribute(HtmlTextWriterAttribute.Href, parentNode.Url);
output.AddAttribute(HtmlTextWriterAttribute.Class, "zz1_TopNavigationMenu_1 ms-topnav zz1_TopNavigationMenu_3");
output.RenderBeginTag(HtmlTextWriterTag.A);
output.Write(parentNode.Title);
output.RenderEndTag();
}

}

/// <summary>
/// Renders the dynamic node.
/// </summary>
/// <param name="parentNode">The parent node.</param>
/// <param name="output">The output.</param>
/// <param name="itemCount">The item count.</param>
/// <param name="level">The level.</param>
/// <param name="isLastColumnDIVClosed">if set to <c>true</c> [is last column DIV closed].</param>
/// <returns>boolean value if the last div for a new column is closed.</returns>
private bool RenderDynamicNode(SiteMapNode parentNode, HtmlTextWriter output, int itemCount, int level, bool isLastColumnDIVClosed)
{
if (level <= DynamicLevel)
{
if (itemsInColumnCounter >0 && itemsInColumnCounter > this.NumberOfItemsPerColumn)
{
if (!isLastColumnDIVClosed)
{
output.RenderEndTag();
isLastColumnDIVClosed = true;
}
output.AddAttribute(HtmlTextWriterAttribute.Class, "column");
output.RenderBeginTag(HtmlTextWriterTag.Div);
itemsInColumnCounter = 0;
isLastColumnDIVClosed = false;
}
else if ((itemsInColumnCounter > 0) && (parentNode.ChildNodes.Count + itemsInColumnCounter + 1 > this.NumberOfItemsPerColumn))
{
if (!isLastColumnDIVClosed)
{
output.RenderEndTag();
isLastColumnDIVClosed = true;
}
output.AddAttribute(HtmlTextWriterAttribute.Class, "column");
output.RenderBeginTag(HtmlTextWriterTag.Div);
itemsInColumnCounter = 0;
isLastColumnDIVClosed = false;
}

if (!string.IsNullOrEmpty(templates[level - 1]))
{
if (itemsInColumnCounter > 0)
{
output.Write("<br style='clear: left' />");
}
string menuitem = templates[level - 1];
menuitem = menuitem.Replace("[LINK]", parentNode.Url);
menuitem = menuitem.Replace("[TITLE]", parentNode.Title);
output.Write(menuitem);
itemsInColumnCounter++;
}

output.RenderBeginTag(HtmlTextWriterTag.Ul);

foreach (SiteMapNode node in parentNode.ChildNodes)
{
itemsInColumnCounter++;
if (node.ChildNodes.Count > 0)
{
RenderDynamicNode(node, output, itemCount, level + 1, isLastColumnDIVClosed);
}
else
{
if (level + 1 <= DynamicLevel)
{
if (!string.IsNullOrEmpty(templates[level]))
{
output.RenderBeginTag(HtmlTextWriterTag.Li);
string menuitem = templates[level];
menuitem = menuitem.Replace("[LINK]", node.Url);
menuitem = menuitem.Replace("[TITLE]", node.Title);
output.Write(menuitem);
output.RenderEndTag();
}
}
}

}

itemCount = 0;
output.RenderEndTag();
}

return isLastColumnDIVClosed;
}
}


sample.sitemap file in _layouts folder

Web.Config Entry





...
...



....
....






Use the Mega menu inside MasterPage






8 comments:

  1. Is that the complete code for the menu? Do you have a sample project that you can email me? my email is davidreesejr[at][gmail][dot][com]

    ReplyDelete
  2. Please Mr Deba , how can we use this on sharepoint ??

    ReplyDelete
  3. Thanks Deba for sharing this,
    do you have a working sample to post here please?

    Many thanks

    Rao

    ReplyDelete
  4. You can use this as any other control in SharePoint 2007.

    ReplyDelete
  5. Deba, I can follow your instructions till the Custom Control.
    How is the Custom Control implemented? Can I use SP2007 Designer? Where does the code go?
    And how is this linked to your Custom List?

    Do I need access to the SharePoint Server or can this be done from the front-end using SharePoint Designer?
    Thanks.

    ReplyDelete
  6. I couldn't get this to work right. There arent many posts for mega menus. I found this, but don't know how it works.
    http://www.archetonomy.com/products/mega-drop-down-professional

    ReplyDelete