UPDATE see the refactored version here
A very common pattern in a lot of web applications is a tabbed menu with the current tab highlighted. Last night I spent some time refactoring the navigation in Golf Trac, and I thought I would post what I did, just in case someone is new to this and goes overkill on the implementation.
First of all, Rails in its entirety thrives off of conventions. It’s inspirational to say the least. I’ve embraced a conventional way of thinking in my tabbed menu implementation, so let’s get down to business.
Tabbed navigation for actions only
For this example, I’ll use a public_controller, which contains a few actions that aren’t restricted behind a login. Just to get you excited, here’s what the end result will be:
navigation ['login','register','about','tour']
You might immediately think that each link passed in is the title of an action and the route is generated by appending _path on the end. Actually, that’s pretty close. The problem I had with that quick-and-dirty implementation was the ambiguity of the named routes. For example, in Golf Trac a user can create a New course and a New round. Well, that means I have two “new” things. I couldn’t generate a named route such as new_path because it’s ambiguous.
To alleviate this problem, I’m using controller.controller_name to make the routes unique. I’ll just give the implementation (I made a pastie, as well) and mention a thing or two about it afterward.
def navigation(links)
returning html = "<ul>" do
links.each do |link|
html << "<li class='#{css_for(link)}'>#{build_link_for(link)}</li>"
end
html << "</ul>"
end
end
You could use content_tag instead of explicitly writing out the <ul> and <li> stuff, but sometimes I find this easier on the eyes. The css_for(link) method just pulls out a lengthy if condition (better readability) to determine if it’s the selected tab, but for completeness, here are the details:
def css_for(link)
controller.action_name.downcase == link.downcase ? 'current' : 'plain'
end
The build_link_for(link) method simply generates the link with the appropriate named route dynamically, but again, here are the details:
def build_link_for(link)
link_to link.capitalize, send("#{link.downcase}_#{controller.controller_name.downcase}_path")
end
Now, that’s how to select the current tab for a list of actions within a controller, but what if you wanted to select the current controller, too (i.e. nested tabs)? It’s simple. All you have to do is check if the controller.controller_name.downcase instead.
Modified for controllers and actions
In Golf Trac, I have a sidebar which has a menu that highlights the current controller, then in the corresponding content area, a set of tabs for each major action within that controller, which also get highlighted upon selection. So my navigation helper is more complex, but not by much:
def navigation(links, from_layout = false)
returning html = "<ul>" do
links.each do |link|
html << "<li class='#{css_for(link, from_layout)}'>#{build_link_for(link, from_layout)}</li>"
end
html << "</ul>"
end
end
Also, you’d have to modify the css_for(link) method to accept the from_layout parameter so it would know if it was supposed to check the controller or action name. Here’s how I’m currently doing that:
def css_for(link, from_layout)
controller.send("#{from_layout ? 'controller_name' : 'action_name'}").downcase == link.downcase ? 'current' : 'plain'
end
It’s basically the same thing, only I’m determining if I need controller_name or action_name based on the from_layout parameter. And the build_link_for(link) method needs updated, too:
def build_link_for(link, from_layout)
controller_path, action_path = "#{link.downcase}_path", "#{link.downcase}_#{controller.controller_name.downcase}_path"
link_to link.capitalize, send("#{from_layout ? controller_path : action_path}")
end
It’s the same deal here. If it’s an action, then I need the route that has the controller name appended to it. The nice thing is the more frequent situation (view templates) would not require a true or false parameter (since it’s defaulted to false), so it keeps the API nice and clean. And you’d only have to add it in your layout once, like so:
navigation ['login','register','about','tour'], true
Here are the routes that would work with the above example:
map.with_options :controller => 'public' do |path|
path.login_public '/login', :action => 'login'
path.register_public '/register', :action => 'register'
path.about_public '/about', :action => 'about'
path.tour_public '/tour', :action => 'tour'
end
TODO: determine a way to not require an extra parameter (from_layout), but have the helper know where it’s being called from and act accordingly.
Conclusion
Currently, I’m using this implementation to navigate around five controllers (in the sidebar) and 13 or so total actions (in the content areas). I personally like passing the text that I want to display as tabs, but that’s just one of the hundred ways to dynamically construct a tabbed menu in Rails. Whether or not you do something like I’ve shown above, I strongly recommend you setup some sort of convention to base it on. Be smart about the design and you can get so many things for free.