6 Tweaks for your ASP:Menu Control
Prologue
I have slowly been working on
redesigning
recreating our company website in all ASP.Net 2.0. Currently it's half ASP.Net 1.1 and the other half is classic ASP (ugh). As such, I've been trying to make use of some of the new tools that come with ASP.Net 2.0. One of such tool is the asp:Menu control. Very cool, but I've had some difficulty getting to to work quite like I want it to. There isn't much on the web about some of the problems I was having so thought I'd post about some of the solutions I found. Originally I titled this post 25 ways to skin your ASP:Menu control, but I didn't find 25 things to talk about, and I wasn't doing much of it in a skin file... so I changed it. I really wanted 10 things, but a couple of the formatting problems were specifically because of my layout and CSS and not the control. So here are 6 tweaks I settled on...
Problem
One of the things that I wanted to do with our site recreation was to make use of the ASP.Net Menu control. This would make it much easier to administrate changes to the menu structure. Right now, it's pretty confusing to create a new menu items on our website. The new ASP:Menu control does what our current site is doing, so it should be a straight move over and then I can just change the web.sitemap file. That works find, but getting it to actually look and feel like our existing menu has been a challenge. Below, I'll detail some of the issues I had and what I did to fix them.
Solution
Ok, to get started let's do a little setup. Create an aspx page and name it MenuPage.aspx, also add a web.sitemap item and leave it with the same name. We also will do some of the defining of the Menu control via a skin file so let's add a Theme folder (choose Add New Asp.net folder when you right click and choose theme) and name it default. Now, add a skin file to the theme and call it menu.skin. Finally, open the html source of the MenuPage.aspx and in the @page directive, add Theme="Default". Ok, now we need to add a menu control (from the Navigation section) to our MenuPage.aspx page, drag it from the toolbox and drop it on the page. Also drag over a SiteMapDataSource control (from the data section of the toolbox). Now click on the menu control and choose our datasource as the datasource. One more thing and we should be ready to go.
Open your web.sitemap and add some content. For our purposes, here's all we need:
<?xml version="1.0" encoding="utf-8" ?>
<siteMap xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" >
<siteMapNode url="" title="Top Branch" description="">
<siteMapNode url="" title="Sub Item 1" description="" >
<siteMapNode url="" title="Sub Item 1A" description="" />
<siteMapNode url="" title="Sub Item 1B" description="" />
</siteMapNode>
<siteMapNode url="" title="Sub Item 2" description="" >
<siteMapNode url="" title="Sub Item 2A" description="" />
<siteMapNode url="" title="Sub Item 2B" description="" />
</siteMapNode>
</siteMapNode>
</siteMap>
You'll notice that this is an xml file. We have three levels of menu. The top branch, two sub items and two items below the two sub items. View your MenuPage.aspx and see if the menu works as it should. Now we're setup and ready to begin.
Hiding the 'Top Branch' node
Right off the bat, I found something interesting about the web.sitemap file. If you notice, we have one top level element, and all others are children to it (There's a reason for that). I wanted my menu to have 8 or 10 'first level' options so that the menu would be a list of all the options. Well, the problem is that according to the XML specification, you must have 1 and only 1 top level item. You can have as many 2nd level items as you please, but only 1 top level. So I created all MY 'top level' items as 2nd level items and began searching for a way to change things. The menu control has LOTS of attributes to consider. Getting the second level items to display as though they are first level items is rather easy, in your .aspx file, add the following attribute to your menu control:
StaticDisplayLevels="2"
Now view your page, we get Level 1 and Level 2 items as static items on our menu. But I don't want the Top level item to show up. There must be another property to fix the problem. Search up and down the menu control and you'll find nothing to help you here (and there are a lot of properties you can change for the menu control...). Well, the fix isn't the menu control, it's the SiteMapDataSource control. Add the following attribute to your SiteMapDataSource control:
ShowStartingNode="false"
Now view your page. Starting node - Gone. But now we've got a problem. our entire menu is displayed by default. To fix it, go back and change your Menu control to show:
StaticDisplayLevels="1"
Refresh your page. Viola, it's displaying as I want it. We were hiding the starting node, so our 2 static levels to show were the sub items and their children.
Finger Pointer Not Showing
Ok, so now that we've got our levels as we wanted them put your mouse over the menu and you'll notice that you don't get a finger indicating that it's a link. I won't confuse the point by trying to do all the things that I attempted to get it work, but here's the rub, if the url in the sitemap file (as ours is) is blank, then there's no finger. The control is smart enough to figure out when there isn't a url and not enable a link in the menu. You can even wrap your menu control in a div and add the cursor change as a CSS style, it won't work 100% properly. BUT add a url to the web.sitemap and it takes care of itself. My problem was that I hadn't created the pages for the menu to link to, so I left the Url's blank. So for our project, modify the web.sitemap and add URLs to each node, but let's make it easy on ourselves... just add nothing.aspx as the url to all our 7 nodes so they look like this:
url="nothing.apsx"
Now refresh and ERROR!... ?
Using the Same Url is a Problem
After the refresh, you get a chastisement from the server:
'Multiple nodes with the same URL '/.../nothing.aspx' were found. XmlSiteMapProvider requires that sitemap nodes have unique URLs.'
You can't have two nodes with the same URL. What I found in my searching is that the URL is used as the ID when the sitemap file is read into a dictionary (it's used as the unique key to identify the entry in the dictionary). My opinion... not good. Having a little basis in database design, we are taught to never use something that may be repeated as the unique ID. This for instance, is why you use the Social Security number, not their name. There may be 5 John Does in your system, but there should be only one of each SSN. So why someone decided that every node in the menu can't be repeated, I'll never know.
Our website actually has a couple of repeat URL's in the sitemap. We have a link for example to our photos page as a sub item to our 'Press Room' menu item and our 'Community Relations' menu item. So it wasn't just a matter of silly nothing URL's I needed a solution. So what do we do about it? Well, we have a few options (for more info, I found these hints at Raj Kaimal's weblog):
1. You can write your own XmlSiteMapProvider control and override key functions to get the behavior you want (like addNode, FindSiteMapNode etc.). Too much work...
2. You can put the common content into a user control and then create two different pages that display the control (displaying the same content), but have different names. For example, instead of using Records.aspx twice, have AdminRecords.aspx and EmployeeRecords.aspx, and then use the user control to display the common content. Again, while probably useful in certain situations, it wasn't in my case. It just meant two pages where one would do.
3. Add a useless querystring. Basically, a querystring doesn't do anything unless you use it when you get to the destination page. So just add a querystring that isn't used. This satisfies the requirements of the Menu control that URLs are different. So let's add a modify our Web.Sitemap as follows:
url="nothing.apsx?1" '<- Note the bold part is the addition
url="nothing.apsx?2"
url="nothing.apsx?3"
Etc..
That's it, It's that easy. You can do actual full parameters '?x=y', but you don't have to, now all the pages link to the same URL, and .Net lets us.
Creating a 2 Line Menu Item
After I got some of the basics working ok, I ran into another problem. Our existing site (which I'm trying to replicate) has a given width for the menu. If I don't split some of the menu items to a second line, then it doesn't fit. So let's change one of our items so it's longer and see what we can do to fix it. Change Sub Item 1 as follows:
title="A really great title that is long"
Refresh and see what happens. We have one really long title, and one that isn't, and they look kind of funny. But how do we break our menu item into two lines? Is it possible? I looked to see if it had a word wrap option, it does. Add the following to your asp:menu definition:
ItemWrap="true" Width="150px"
Refresh and see, that our line DOES indeed wrap. Problem is that we have unbalanced lines and they look bad. If we could split the line after title, then it would work better. Problem is that if we change the width, another line may give us the same problem. So how do we split it where we WANT it to be split? Well, let's add a <br /> into our web.sitemap where we want the line to break, and see if when the html is rendered we get our break. Change our title to read:
title="A really great title <br /> that is long"
Right off, you'll notice that we get errors reported in our file, but refresh and see what happens anyway. Error there too. We can't enter a '<' into our XML file, because it's a special character. So what do we do? Simple, convert our special characters into their HMTL encoded equivalent. So enter the following instead:
title="A really great title<br /> that is long"
Now, save and refresh and... it works. Basically, we put the HTML codes in to generate our <br />, and now when it is interpreted, it will put in the line break.
Opening an asp:Menu Item in a New Window
Some of the links in my company's website's menu actually open PDF documents rather than linking to pages. All of these pages we want to open in a new window when clicked. How do we get the menu item to open a new window? The Web.Sitemap file doesn't allow us to add a target to the entry (too bad). So what's a guy to do? I suppose that we could go back to our same-URL problem and create a new provider, but why do all that work? The easy answer lies in overriding the menu's MenuItemDataBound event. In my situation all URLs to open in a new window ended in .pdf. So it was easy to determine if the link ended in pdf to just add a target to a new window. So open the code behind file for your MenuPage.aspx and add the following code to the Menu control's MenuItemDataBound event:
If e.Item.NavigateUrl.ToString.EndsWith(".pdf") = True Then
e.Item.Target = "_blank" 'open in blank window
End If
Here's what happens, when ever the menu item is created, we examine the link, if it ends with .pdf, we assign a target for the link. Unless we add stuff to it, the target property is blank. Run your project and see if it works (you might have to find a real .pdf to link to).
Now, what if we want to assign some .pdf's to open in the current window and some to open in a new window? Let's try something... Let's use the siteMapNode's description field to pass a target. We'll use some delimiter characters in our description to denote our target, I'll just use *ts* as the 'targetStart' and *te* as the 'targetEnd'. Now modify your code that assign the MenuItemDataBound function to read follows:
Dim sDescriptionNode As SiteMapNode = e.Item.DataItem
e.Item.Target = GetTarget(sDescriptionNode.Description)
e.Item.ToolTip = e.Item.ToolTip.Replace("*ts*" & e.Item.Target _
& "*te*", "")
This will get our target string and add it as the target, and the remove it from the description and assign it back to the tooltip (which is where description is mapped to). Also, we need to add a target to one of our web.sitemap nodes like so:
description="The real description*ts*_blank*te*"
This says, that _blank will be our target. Now let's add the GetTarget function to our MenuPage.aspx code behind:
Private Function GetTarget(ByVal sDescription As String) As String
If sDescription.Contains("*ts*") = True Then
Dim iStartOfTarget As Integer = sDescription.IndexOf("*ts*")
Dim iEndOfTarget As Integer = (sDescription.IndexOf("*te*") _
- sDescription.IndexOf("*ts*"))
Return sDescription.Substring(iStartOfTarget, iEndOfTarget) _
.Replace("*ts*", "").Replace("*te*", "")
Else
Return ""
End If
End Function
Basically, it just extracts the information between the two tags and returns the part we put in as a target. Run your application and see how it works.
Using An Item Template (Using a "Bullet" Image)
One of the things that I ran into while recreating the menu as we currently have it, was that there was a 'bullet' at the beginning of each item. This was being created with an image . So how do you add an image to the beginning of each item? The answer lies in templates. You can create and assign images to indicate that there are sub-menu items (the arrows on the right), but not as a bullet (on the left). So we must create a template. Since the company only has bullets on the static menu, I only need to create an static item template. We have a couple options for creating the template, we can create it directly in the aspx file, or we can create it in a skin file. For some reason, I can't remember why, I chose to do it in a skin file so we will here too. Open your Menu.skin file and add an asp:menu item. We need to create a StaticItemTemplate. I didn't even actually use a real image, I just created the image control added a border and never assigned a real image URL to it. Go to the source of your skin file and add at StaticItemTemplate between your asp:menu tags. In that template, add an asp:Image control as follows:
<StaticItemTemplate>
<asp:Image BorderWidth="1" BorderColor="black"
Width="1" Height="1" runat="server" />
</StaticItemTemplate>
This will create the image, but how do we create the text and the link? I had to search a while to find this, for some reason it wasn't posted very prevalently. Since we're using a data source, we simply need to bind the control to the values coming out of the datasource. So after the asp:Image control, add an asp:Hyperlink control as follows:
<asp:HyperLink ID="lblMenuItema" runat="server"
NavigateUrl='<%# Eval("NavigateUrl") %>'
Text='<%# Eval("Text") %>' />
We have the control evaluate the NavigateUrl data from the datasource and then do the same for the text to display. Refresh and notice that our item that is split wraps a little strangely, it now puts the second line below the image. This happens because the image is at the beginning of the line and then the line wraps. To solve that, use a table and put the hyperlink and image in separate cells. Then set the valign to middle and the bullet sits in the middle. Modify your code so it looks like this:
<StaticItemTemplate>
<table>
<tr valign="middle">
<td><asp:Image BorderWidth="1" BorderColor="black"
Width="1" Height="1" runat="server" />
</td>
<td><asp:HyperLink ID="lblMenuItema" runat="server"
NavigateUrl='<%# Eval("NavigateUrl") %>'
Text='<%# Eval("Text") %>' />
</td>
</tr>
</table>
</StaticItemTemplate>
Refresh and check it out. There you go, a customized menu. It's not that its hard, it isn't, but it was hard to find something that showed how to do it.
Epilogue
The new menu controls are an enormous 'bout time' feature that really makes modification of they menus simple. There are some tricks to using it, and diligent research and some luck can overcome the limitations.