Of course, it's possible to do this using ordinary hyperlinks:
<li><a href="i.html">I. Part One</a></li>
<li><a href="ii.html">II. Part Two</a></li>
In this simple design, we link to separate pages for the two main headings of the outline. Each of those pages would then link to additional sub-pages, and so on.
No problem! If you like, simply skip to the end of the article and you'll find simple instructions for creating fully collapsible outlines. If you do choose to keep reading, you'll learn how it all works. The choice is yours!
So how do we cope? My solution begins with a perfectly normal nested list, like this short outline for a presentation regarding a chain of ice cream parlors:
... etc ...
Can we finish the job entirely with CSS? Unfortunately, no. While something resembling an outline could be created with the CSS hover attribute, this would require the user to keep the mouse over the parts of the outline they want to see. If the user wants to expand several portions of the outline at once, they're out of luck. And trying to understand the information about the outline while worrying about the mouse leaving the "hot zone" and closing the outline is never pleasant.
Hiding elements with the display CSS propertyBefore we can expand and collapse our outline, we need to hide the elements that should be collapsed at the beginning. Fortunately, this isn't difficult. CSS provides us with the display property, which decides whether an element should actually be part of the page layout or not.
It is important to understand that the display and visibility properties are different. visibility: hidden prevents the user from seeing the element, but it still occupies space in the page. That's not right for a collapsible outline— we don't want giant blocks of white space! Instead, we use display: none, which ensures that the element is not part of the page layout at all. And when we're ready to bring the element back, we'll set it to display: block, which allows that ul element to be laid out in the normal way.
Here is an example of a sublist that is currently collapsed (not displayed):
<ul style="display: none">
... List items go here ...
If you are not willing to make the effort to do that, then I strongly suggest you follow my suggestion and allow the collapsed part of the outline to be hidden after the page loads.
Here's an example of an li element with an onClick handler:
<li onClick="outlineClick()">Item One</li>
This allows the user to click anywhere in the list item text. As I mentioned, however, only link elements will fire an onClick handler when the user navigates with the keyboard instead of the mouse.
So, here is an alternative that ought to allow keyboard navigation:
<li><a onClick="outlineClick()" href="#">Item One</a></li>
Unfortunately, there is a problem with this approach. When the outlineClick function is called, it has to know which sublist needs to be expanded! How do we get that information?
The answer depends on the web browser being used. In Internet Explorer, we can figure out which element was clicked on by looking at window.event.srcElement. In all other browsers, our outlineClick function will receive an Event object as a parameter, and we can look at the target property of that object to figure out what was clicked on.
This works fine for mouse clicks. But what about links followed via the keyboard? You would be forgiven for expecting that these onClick calls would also receive an event object. That would certainly make sense. But unfortunately it doesn't happen.
How can we solve the problem? Instead of inferring which link was selected, we'll have to pass that information ourselves when we create the onClick attribute for the link. And that brings us to the big question.
But how does the magic work? outlineInit begins by fetching a list of elements in the page that have the outline class:
var elements = outlineGetTopLevelLists();
Next, the code calls outlineInitOutline to look at each individual top-level outline in the page (which allows you to have more than one). This function is simple: it fetches the list's child nodes via the childNodes property of the outline element, and then looks for children with a node name (HTML element name) of LI. These are the individual list items.
After hiding the nested list, we call outlineInitOutline on the nested list, so that all of the children of that list can be hidden... and so on, as many levels down as necessary.
As we go along, we build an array of the li elements that turn out to have nested lists beneath them. But why is this useful? You're about to find out!
Solving the "Why Are We Here?" ProblemRemember the problem we had with the onClick handler? When the user makes their choice via the keyboard, the onClick handler doesn't know which element was chosen. What we need is a way to uniquely identify the clickable items (the "outline" elements), so that we can pass that unique identifier to our onClick handler.
By building an array of outline elements (the li elements that have lists nested beneath them), we give each outline element an identifier (its index in the array). That way, when we create a link element on the fly and give it an onClick handler for keyboard events, we can include the identifier right in the original HTML for the onClick attribute.
Elegant, isn't it. Here's the code:
var len = outlineItems.length;
outlineItems[len] = item;
var span = document.createElement("span");
span.innerHTML = "<a href='#' " +
"onClick='outlineItemClickByOffset(" + len +
"); return false' " +
"<img class='oimg' alt='Open' src='oopen.png'></a>";
item.onclick = outlineItemClick;
What's happening here? First, we check how long the outlineItems array already is. That value will be the index for the next item— the new outline item we are processing now.
Next, we add the new item to the outlineItems array and get ready to create a keyboard-friendly link element, containing a "plus sign" image that the user can select with either the keyboard or the mouse.
You might wonder why I don't simply do this by changing the innerHTML property of the list item, like this:
item.innerHTML = "<a onClick='et cetera...'>" +
item.innerHTML + "</a>";
While this looks like a quick and dirty solution, it has a fatal flaw. When we modify innerHTML for the item, we recreate all of the HTML elements beneath it. And that has two big problems:
1. It is potentially slow if the outline is large and complicated.
2. Any outline elements beneath this level that we have already stored in the outlineItems array get replaced... which means the entries in that array are no longer valid.
We could get around this by creating the link and image elements purely using DOM functions like document.createElement and document.setAttribute, but frankly... it's a pain. And more importantly, Internet Explorer won't play along. An onClick handler created with the setAttribute function simply didn't work in my Internet Explorer tests.
Fortunately, we can work around it by creating a span element with document.createElement, then tucking the link and image elements inside it by setting span.innerHTML. Then we insert the span element at the beginning of our list item with the item.insertBefore method, which does not ruin our patiently collected outlineItems array. This approach keeps our code simple... okay, relatively simple... and avoids the Internet Explorer bug.
We're nearly there— just a few details left! We don't want to put the entire contents of the list item inside a link element, because that would break any hyperlinks in our outline. ButiIt would still be nice to let the user click on the text if they wish, rather than on the "open/close" images. And we can still do that.
As I mentioned, we can't call setAttribute to add an onClick handler in Internet Explorer— it just doesn't work. But we can set item.onclick to any function we like, and that works in both browsers. What it doesn't do is give us an opportunity to pass the ID of our list item.
Fortunately, this time we are talking about a mouse event for certain. So we can set our onclick handler to a function that relies on Event.target or window.event.srcElement to figure out what is happening. See outlineGetTarget for the gory details.
When It's Time To Grow... Or ShrinkWe've created a fully collapsed outline (except for the top level, of course). And we've found a way to know which outline element has been clicked upon or selected with the keyboard. Now, how do we finish the job by responding to that event?
The outlineItemClickBody function does the work. Here we fetch the child nodes of the outline element, sort through them to find nested lists, and toggle their style.display properties between none and block.
But how do we make it clear to the user that the outline has been expanded? The user will probably see the nested list underneath, of course, but we can do even better by changing our "open" icon to a "close" icon. We do that by fetching the image element within the link and changing its src and alt attributes on the fly.
Of course, "fetching the image element within the link" is easier said than done. To see how, just look at the outlineGetDescendantWithClassName function. This function, which is similar to outlineGetTopLevelLists, walks through the child nodes beneath the outline element. Each one is checked to see whether it is a member of the oimg class. If not, we check the children of that element before moving on... and so on. We keep going until the first matching element is found and returned.
I could have written this function to simply return the first grandchild of the outline element, but that approach is very brittle— what if you decide to make a seemingly minor change to the appearance of my links, and the img element is no longer the first grandchild? By locating the correct image element in this way, the code becomes more tolerant of changes.
The Devil Is In The DetailsThat's it... almost! There are one or two important details. The nastiest of these is event propagation.
When an onClick handler for a list item is nested inside another list item... which has its own onClick handler... which handler is actually called? The answer: it depends. But browsers do propagate the event to other handlers beyond the innermost item the user thinks they have selected. And that causes outlines to open and then immediately close again, or behave in other undesirable ways.
Fortunately, Internet Explorer and other browsers both allow us to prevent this behavior. In Internet Explorer, we do it by setting the cancelBubble property on the event. In other, more standards-compliant browsers, we do it by calling the evt.stopPropagation method of the event. You can find this code in the outlineGetTarget function.
A second, less crucial issue is the mouse pointer. When the mouse pointer is over a link or other interactive item, it should change its appearance, but this is not always automatic. We solve this problem by setting the style.cursor property to pointer on all of the list items that contain nested lists.
Enough Talk, Just Give Me The Code!Of course! Here's how to get the job done:
1. Check out the live demo to see what you'll be getting. My example outline is a small one, but yours can be much more complicated.
2. Download outline.zip and extract it to your hard drive.
3. Upload outline.js, outline.css, oopen.png and oclose.png to your website. Do not upload test.html, that's just an example for you to learn from.
4. Add a link to outline.css in the head element of your page:
<link href="outline.css" rel="stylesheet" type="text/css">
Note: if outline.css is not in the same folder with the page, make sure you change the href appropriately.
5. Load outline.js in the head element of your page:
Of course, you must change the src attribute if outline.js is not in the same directory.
6. Call outlineInit() from your onLoad handler:
If you need other onLoad handlers, just separate the function calls with semicolons.
7. Write a perfectly normal nested list— but make sure the top-level ul element, and only that element, is a member of the outline class:
Got a LiveJournal account? Keep up with the latest articles in this FAQ by adding our syndicated feed to your friends list!