Tabbed navigation tip for Rails
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:
1 | 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.
1 2 3 4 5 6 7 8 | 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:
1 2 3 | 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:
1 2 3 | 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:
1 2 3 4 5 6 7 8 | 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:
1 2 3 | 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:
1 2 3 4 | 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:
1 2 | # views/layouts/[whatever].rhtml navigation ['login','register','about','tour'], true |
Here are the routes that would work with the above example:
1 2 3 4 5 6 7 | # config/routes.rb 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.

Chris Thursday, 27 Sep, 2007 Posted at 08:51AM
You also make a helper “subnavigation” or something that simply calls the navigation helper with the “false” parameter.
Ryan Thursday, 27 Sep, 2007 Posted at 01:10PM
Thanks… I actually ended up doing that. I hated having that
truetrailing the list of tabs.Andrew Vit Monday, 01 Oct, 2007 Posted at 02:29AM
I just happened to implement something similar today. My current project has a few static pages which appear as separate tabs on the top level navigation. Since it doesn’t make sense to create a separate controller for one page, just to highlight the tab, I used route parameters as a solution instead:
Passing in the “nav_section” parameter allows you to decouple the selected tab from the name of the controller.
Ryan Monday, 01 Oct, 2007 Posted at 04:34AM
That’s not a bad idea, and one that I have never thought of. But I try to avoid passing parameters around when I can.
I personally don’t think of adding the controller name to the route as “coupling” in the bad sense (it’s only that way for the tabs, which also helps to quickly see which routes are for tabs in the
routes.rbfile). I’m of the opinion that anytime you’re following a convention it’s a good thing. Stuff just works and it’s consistent.But I definitely get what you’re saying, and it is also a pretty nice way to handle navigation.