Article
Script Smarter: Quality JavaScript from Scratch
Finding the Position of an Element
Knowing the exact position of an element is very helpful when you wish to position other elements relative to it. However, because of different browser sizes, font sizes, and content lengths, it's often impossible to hard-code the position of an element before you load a page. JavaScript offers a method to ascertain any element's position after the page has been rendered, so you can know exactly where your elements are located.
Solution
The offsetTop and offsetLeft properties tell you the distance between the top of an element and the top of its offsetParent. But what is offsetParent? Well, it varies widely for different elements and different browsers. Sometimes it's the immediate containing element; other times it's the html element; at other times it's nonexistent.
Thankfully, the solution is to follow the trail of offsetParents and add up their offset positions -- a method that will give you the element's accurate absolute position on the page in every browser.
If the element in question has no offsetParent, then the offset position of the element itself is enough; otherwise, we add the offsets of the element to those of its offsetParent, then repeat the process for its offsetParent (if any):
Example 13.17. find_position_of_element.js (excerpt)
function getPosition(theElement)
{
var positionX = 0;
var positionY = 0;
while (theElement != null)
{
positionX += theElement.offsetLeft;
positionY += theElement.offsetTop;
theElement = theElement.offsetParent;
}
return [positionX, positionY];
}
IE 5 for Mac Bug
Internet Explorer 5 for Mac doesn't take the body's margin or padding into account when calculating the offset dimensions, so if you desire accurate measurements in this browser, you should have zero margins and padding on the body.
Discussion
The method above works for simple and complex layouts; however, you may run into problems when one or more of an element's ancestors has its CSS position property set to something other than static (the default).
There are so many possible combinations of nested positioning and browser differences that it's almost impossible to write a script that takes them all into account. If you are working with an interface that uses a lot of relative or absolute positioning, it's probably easiest to experiment with specific cases and write special functions to deal with them. Here are just a few of the differences that you might encounter:
- In Internet Explorer for Windows and Mozilla/Firefox, any element whose parent is relatively positioned will not include the parent's border in its own offset; however, the parent's offset will only measure to the edge of its border. Therefore, the sum of these values will not include the border distance.
- In Opera and Safari, any absolutely or relatively positioned element whose offsetParent is the body will include the body's margin in its own offset. The body's offset will include its own margin as well.
- In Internet Explorer for Windows, any absolutely positioned element inside a relatively positioned element will include the relatively positioned element's margin in its offset. The relatively positioned element will include its margin as well.
Detecting the Position of the Mouse Cursor
When working with mouse events, such as mouseover or mousemove, you will often want to use the coordinates of the mouse cursor as part of your operation (e.g., to position an element near the mouse). The solution explained below is actually a more reliable method of location detection than the element position detection method we discussed in the section called "Finding the Position of an Element", so if it's possible to use the following solution instead of the previous one, go for it!
Solution
The event object contains everything you need to know to work with the position of the cursor, although a little bit of object detection is required to ensure you get equivalent values across all browsers.
The standard method of obtaining the cursor's position relative to the entire page is via the pageX and pageY properties of the event object. Internet Explorer doesn't support these properties, but it does include some properties that are almost the ones we want. clientX and clientY are available in Internet Explorer, though they measure the distance from the mouse cursor to the edges of the browser window. In order to find the position of the cursor relative to the entire page, we need to add the current scroll position to these dimensions. This technique was covered in Chapter 7, Working with Windows and Frames; let's use the getScrollingPosition function from that solution to retrieve the required dimensions:
Example 13.18. detect_mouse_cursor.js (excerpt)
function displayCursorPosition(event)
{
if (typeof event == "undefined")
{
event = window.event;
}
var scrollingPosition = getScrollingPosition();
var cursorPosition = [0, 0];
if (typeof event.pageX != "undefined" &&
typeof event.x != "undefined")
{
cursorPosition[0] = event.pageX;
cursorPosition[1] = event.pageY;
}
else
{
cursorPosition[0] = event.clientX + scrollingPosition[0];
cursorPosition[1] = event.clientY + scrollingPosition[1];
}
var paragraph = document.getElementsByTagName("p")[0];
paragraph.replaceChild(document.createTextNode(
"Your mouse is currently located at: " + cursorPosition[0] +
"," + cursorPosition[1]), paragraph.firstChild);
return true;
}
clientX/clientY are valid W3C DOM event properties that exist in most browsers, so we can't rely on their existence as an indication that we need to use them. Instead, within our event handler, we test for the existence of pageX. Internet Explorer for Mac does have pageX, but it's an incorrect value, so we must also check for x. x is actually a nonstandard property, but most browsers support it (the exceptions being Opera 8+ and Internet Explorer). It's okay that Opera 8+ doesn't support x, because the else statement is actually a cross-browser method for calculating the mouse cursor position except in Safari, which incorrectly gives clientX the same value as pageX. That's why we still need to use both methods of calculating the cursor position.
Displaying a Tooltip when you Mouse Over an Element
Tooltips are a helpful feature in most browsers, but they can be a bit restrictive if you plan to use them as parts of your interface. If you'd like to use layers that appear when you want them to, aren't truncated, and can contain more than plain text, why not make your own enhanced tooltips?
Solution
For this example, we'll apply a class, hastooltip, on all the elements for which we'd like tooltips to appear. We'll get the information that's going to appear in the tooltip from each element's title attribute:
Example 13.19. tooltips.html (excerpt)
<p>
These are the voyages of the <a class="hastooltip"
href="enterprise.html" title="USS Enterprise (NCC-1701) ...">
starship Enterprise</a>.
</p>
From our exploration of browser events earlier in this chapter, you'll probably already have realized that we need to set up some event listeners to let us know when the layer should appear and disappear.
Tooltips classically appear in a fixed location when you mouse over an element, and disappear when you mouse out. Some implementations of JavaScript tooltips also move the tooltip as the mouse moves over the element, but I personally find this annoying. In this solution, we'll focus on the mouseover and mouseout events:
Example 13.20. tooltips.js (excerpt)
addLoadListener(initTooltips);
function initTooltips()
{
var tips = getElementsByAttribute("class", "hastooltip");
for (var i = 0; i < tips.length; i++)
{
attachEventListener(tips[i], "mouseover", showTip, false);
attachEventListener(tips[i], "mouseout", hideTip, false);
}
return true;
}
We've already coded quite a few of the functions in this script, including addLoadListener from Chapter 1, Getting Started with JavaScript, getElementsByAttribute from Chapter 5, Navigating the Document Object Model, and the attachEventListener function that we created earlier in this chapter, so the bulk of the code is in the event listener functions:
Example 13.21. tooltips.js (excerpt)
function showTip(event)
{
if (typeof event == "undefined")
{
event = window.event;
}
var target = getEventTarget(event);
while (target.className == null ||
!/(^| )hastooltip( |$)/.test(target.className))
{
target = target.parentNode;
}
var tip = document.createElement("div");
var content = target.getAttribute("title");
target.tooltip = tip;
target.setAttribute("title", "");
if (target.getAttribute("id") != "")
{
tip.setAttribute("id", target.getAttribute("id") + "tooltip");
}
tip.className = "tooltip";
tip.appendChild(document.createTextNode(content));
var scrollingPosition = getScrollingPosition();
var cursorPosition = [0, 0];
if (typeof event.pageX != "undefined" &&
typeof event.x != "undefined")
{
cursorPosition[0] = event.pageX;
cursorPosition[1] = event.pageY;
}
else
{
cursorPosition[0] = event.clientX + scrollingPosition[0];
cursorPosition[1] = event.clientY + scrollingPosition[1];
}
tip.style.position = "absolute";
tip.style.left = cursorPosition[0] + 10 + "px";
tip.style.top = cursorPosition[1] + 10 + "px";
document.getElementsByTagName("body")[0].appendChild(tip);
return true;
}
After getting a cross-browser event object, and iterating from the base event target element to one with a class of hastooltip, showtip goes about creating the tooltip (a div). The content for the tooltip is taken from the title attribute of the target element, and placed into a text node inside the tooltip.
To ensure that the browser doesn't display a tooltip of its own on top of our enhanced tooltip, the title of the target element is then cleared -- now, there's nothing for the browser to display as a tooltip, so it can't interfere with the one we've just created. Don't worry about the potential accessibility issues caused by removing the title: we'll put it back later.
Controlling Tooltip Display in Opera
Opera still displays the original title even after we set it to an empty string. If you wish to avoid tooltips appearing in this browser, you'll have to stop the default action of the mouseover using the stopDefaultAction function from the section called "Handling Events", the first section of this chapter. Be aware that this will also affect other mouseover behavior, such as the status bar address display for hyperlinks.
To provide hooks for the styling of our tooltip, we assign the tooltip element an ID that's based on the target element's ID (targetIDtooltip), and set a class of tooltip. Although this approach allows for styles to be applied through CSS, we are unable to calculate the tooltip's position ahead of time, so we must use the coordinates of the mouse cursor, as calculated when the event is triggered, to position the tooltip (with a few extra pixels to give it some space).
All that remains is to append the tooltip element to the body, so it will magically appear when we mouse over the link! With a little bit of CSS, it could look like Figure 13.1, "A dynamically generated layer that appears on mouseover".

Figure 13.1. A dynamically generated layer that appears on mouseover
When the mouse is moved off the element, we delete the tooltip from the document, and it will disappear:
Example 13.22. tooltips.js (excerpt)
function hideTip(event)
{
if (typeof event == "undefined")
{
event = window.event;
}
var target = getEventTarget(event);
while (target.className == null ||
!/(^| )hastooltip( |$)/.test(target.className))
{
target = target.parentNode;
}
if (target.tooltip != null)
{
target.setAttribute("title",
target.tooltip.childNodes[0].nodeValue);
target.tooltip.parentNode.removeChild(target.tooltip);
}
return false;
}
Earlier, in showTip, we created a reference to the tooltip element as a property of the target element. Having done that, we can remove it here without needing to search through the entire DOM. Before we remove the tooltip, we retrieve its content and insert it into the title of the target element, so we can use it again later.
Do those Objects Exist?
You should check that objects created in other event listeners actually exist before attempting to manipulate them, because events can often misfire, and you can't guarantee that they will occur in a set order.
Discussion
One problem with the code above is that if the target element is close to the right or bottom edge of the browser window, the tooltip will be cut off. To avoid this, we need to make sure there's enough space for the tooltip, and position it accordingly.
By checking, in each dimension, whether the mouse position is less than the browser window size minus the tooltip size, we can tell how far to move the layer in order to get it onto the screen:
Example 13.23. tooltips2.js (excerpt)
function showTip(event)
{
if (typeof event == "undefined")
{
event = window.event;
}
var target = getEventTarget(event);
while (target.className == null ||
!/(^| )hastooltip( |$)/.test(target.className))
{
target = target.parentNode;
}
var tip = document.createElement("div");
var content = target.getAttribute("title");
target.tooltip = tip;
target.setAttribute("title", "");
if (target.getAttribute("id") != "")
{
tip.setAttribute("id", target.getAttribute("id") + "tooltip");
}
tip.className = "tooltip";
tip.appendChild(document.createTextNode(content));
var scrollingPosition = getScrollingPosition();
var cursorPosition = [0, 0];
if (typeof event.pageX != "undefined" &&
typeof event.x != "undefined")
{
cursorPosition[0] = event.pageX;
cursorPosition[1] = event.pageY;
}
else
{
cursorPosition[0] = event.clientX + scrollingPosition[0];
cursorPosition[1] = event.clientY + scrollingPosition[1];
}
tip.style.position = "absolute";
tip.style.left = cursorPosition[0] + 10 + "px";
tip.style.top = cursorPosition[1] + 10 + "px";
tip.style.visibility = "hidden";
document.getElementsByTagName("body")[0].appendChild(tip);
var viewportSize = getViewportSize();
if (cursorPosition[0] - scrollingPosition[0] + 10 +
tip.offsetWidth > viewportSize[0] - 25)
{
tip.style.left = scrollingPosition[0] + viewportSize[0] - 25 -
tip.offsetWidth + "px";
}
else
{
tip.style.left = cursorPosition[0] + 10 + "px";
}
if (cursorPosition[1] - scrollingPosition[1] + 10 +
tip.offsetHeight > viewportSize[1] - 25)
{
if (event.clientX > (viewportSize[0] - 25 - tip.offsetWidth))
{
tip.style.top = cursorPosition[1] - tip.offsetHeight - 10 +
"px";
}
else
{
tip.style.top = scrollingPosition[1] + viewportSize[1] -
25 - tip.offsetHeight + "px";
}
}
else
{
tip.style.top = cursorPosition[1] + 10 + "px";
}
tip.style.visibility = "visible";
return true;
}
This function is identical to the previous version until we get to the insertion of the tooltip element. Just prior to inserting the element, we set its visibility to "hidden". This means that when it's placed on the page, the layer will occupy the same space it would take up if it were visible, but the user won't see it on the page. This allows us to measure the tooltip's dimensions, then reposition it without the user seeing it flash up in its original position.
In order to detect whether the layer displays outside of the viewport, we use the position of the cursor relative to the viewport. This could theoretically be obtained by using clientX/clientY, but remember: Safari gives an incorrect value for this property. Instead, we use our cross-browser values inside cursorPosition and subtract the scrolling position (which is the equivalent of clientX/clientY). The size of the viewport is obtained using the getViewportSize function we created in Chapter 7, Working with Windows and Frames, then, for each dimension, we check whether the cursor position plus the size of the layer is greater than the viewport size (minus an allowance for scrollbars).
If part of the layer is going to appear outside the viewport, we position it by subtracting its dimensions from the viewport size; otherwise, it's positioned normally, using the cursor position.
The only other exception to note is that if the layer would normally appear outside the viewport in both dimensions, when we are positioning it vertically, it is automatically positioned above the cursor. This prevents the layer from appearing directly on top of the cursor and triggering a mouseout event. It also prevents the target element from being totally obscured by the tooltip, which would prevent the user from clicking on it.
Measuring Visible Tooltip Dimensions
In order for the dimensions of the tooltip to be measured it must first be appended to the document. This will automatically make it appear on the page, so to prevent the user seeing it display in the wrong position, we need to hide it. We do so by setting its visibility to "hidden" until we have finalized the tooltip's position.
We can't use the more familiar display property here, because objects with display set to "none" are not rendered at all, so they have no dimensions to measure.
Sorting Tables by Column
Tables can be a mine of information, but only if you can understand them properly. Having the ability to sort a table by its different columns allows users to view the data in a way that makes sense to them, and ultimately provides the opportunity for greater understanding.
Solution
To start off, we'll use a semantically meaningful HTML table. This will provide us with the structure we need to insert event listeners, inject extra elements, and sort our data:
Example 13.24. sort_tables_by_columns.html (excerpt)
<table class="sortableTable" cellspacing="0"
summary="Statistics on Star Ships">
<thead>
<tr>
<th class="c1" scope="col">
Star Ship Class
</th>
<th class="c2" scope="col">
Power Output (Terawatts)
</th>
<th class="c3" scope="col">
Maximum Warp Speed
</th>
<th class="c4" scope="col">
Captain's Seat Comfort Factor
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="c1">
USS Enterprise NCC-1701-A
</td>
<td class="c2">
5000
</td>
<td class="c3">
6.0
</td>
<td class="c4">
4/10
</td>
</tr>
First, we need to set up event listeners on each of our table heading cells. These will listen for clicks to our columns, and trigger a sort on the column that was clicked:
Example 13.25. sort_tables_by_columns.js (excerpt)
function initSortableTables()
{
if (identifyBrowser() != "ie5mac")
{
var tables = getElementsByAttribute("class", "sortableTable");
for (var i = 0; i < tables.length; i++)
{
var ths = tables[i].getElementsByTagName("th");
for (var k = 0; k < ths.length; k++)
{
var newA = document.createElement("a");
newA.setAttribute("href", "#");
newA.setAttribute("title",
"Sort by this column in descending order");
for (var m = 0; m < ths[k].childNodes.length; m++)
{
newA.appendChild(ths[k].childNodes[m]);
}
ths[k].appendChild(newA);
attachEventListener(newA, "click", sortColumn, false);
}
}
}
return true;
}
Internet Explorer 5 for Mac has trouble dealing with dynamically generated table content, so we have to specifically exclude it from making any of the tables sortable.
Only tables with the class sortableTable will be turned into sortable tables, so initSortableTable navigates the DOM to find the table heading cells in these tables. Once they're found, the contents of each heading cell are wrapped in a hyperlink -- this allows keyboard users to select a column to sort the table by -- and an event listener is set on these links to monitor click events, and execute sortColumn in response. The title attribute of each link is also set, providing the user with information on what will happen when the link is clicked.
The sortColumn function is fairly lengthy, owing to the fact that it must navigate and rearrange the entire table structure each time a heading cell is clicked:
Example 13.26. sort_tables_by_columns.js (excerpt)
function sortColumn(event)
{
if (typeof event == "undefined")
{
event = window.event;
}
var targetA = getEventTarget(event);
while (targetA.nodeName.toLowerCase() != "a")
{
targetA = targetA.parentNode;
}
var targetTh = targetA.parentNode;
var targetTr = targetTh.parentNode;
var targetTrChildren = targetTr.getElementsByTagName("th");
var targetTable = targetTr.parentNode.parentNode;
var targetTbody = targetTable.getElementsByTagName("tbody")[0];
var targetTrs = targetTbody.getElementsByTagName("tr");
var targetColumn = 0;
for (var i = 0; i < targetTrChildren.length; i++)
{
targetTrChildren[i].className = targetTrChildren[i].className.
replace(/(^| )sortedDescending( |$)/, "$1");
targetTrChildren[i].className = targetTrChildren[i].className.
replace(/(^| )sortedAscending( |$)/, "$1");
if (targetTrChildren[i] == targetTh)
{
targetColumn = i;
if (targetTrChildren[i].sortOrder == "descending" &&
targetTrChildren[i].clicked)
{
targetTrChildren[i].sortOrder = "ascending";
targetTrChildren[i].className += " sortedAscending";
targetA.setAttribute("title",
"Sort by this column in descending order");
}
else
{
if (targetTrChildren[i].sortOrder == "ascending" &&
!targetTrChildren[i].clicked)
{
targetTrChildren[i].className += " sortedAscending";
}
else
{
targetTrChildren[i].sortOrder = "descending";
targetTrChildren[i].className += " sortedDescending";
targetA.setAttribute("title",
"Sort by this column in ascending order");
}
}
targetTrChildren[i].clicked = true;
}
else
{
targetTrChildren[i].clicked = false;
if (targetTrChildren[i].sortOrder == "ascending")
{
targetTrChildren[i].firstChild.setAttribute("title",
"Sort by this column in ascending order");
}
else
{
targetTrChildren[i].firstChild.setAttribute("title",
"Sort by this column in descending order");
}
}
}
var newTbody = targetTbody.cloneNode(false);
for (var i = 0; i < targetTrs.length; i++)
{
var newTrs = newTbody.childNodes;
var targetValue = getInternalText(
targetTrs[i].getElementsByTagName("td")[targetColumn]);
for (var j = 0; j < newTrs.length; j++)
{
var newValue = getInternalText(
newTrs[j].getElementsByTagName("td")[targetColumn]);
if (targetValue == parseInt(targetValue, 10) &&
newValue == parseInt(newValue, 10))
{
targetValue = parseInt(targetValue, 10);
newValue = parseInt(newValue, 10);
}
else if (targetValue == parseFloat(targetValue) &&
newValue == parseFloat(newValue))
{
targetValue = parseFloat(targetValue, 10);
newValue = parseFloat(newValue, 10);
}
if (targetTrChildren[targetColumn].sortOrder ==
"descending")
{
if (targetValue >= newValue)
{
break;
}
}
else
{
if (targetValue <= newValue)
{
break;
}
}
}
if (j >= newTrs.length)
{
newTbody.appendChild(targetTrs[i].cloneNode(true));
}
else
{
newTbody.insertBefore(targetTrs[i].cloneNode(true),
newTrs[j]);
}
}
targetTable.replaceChild(newTbody, targetTbody);
stopDefaultAction(event);
return false;
}
The first for loop that occurs after all the structural variables have been defined sets the respective states for each of the table heading cells when one of them is clicked. Not only are classes maintained to identify the heading cell on which the table is currently sorted, but a special sortOrder property is maintained on each cell to determine the order in which that column is sorted. Initially, a column will be sorted in descending order, but if a heading cell is clicked twice consecutively, the sort order will be changed to reflect an ascending sequence. Each heading cell remembers the sort order state it exhibited most recently, and the column is returned to that state when its heading cell is re-selected. The title of the hyperlink for a clicked heading cell is also rewritten depending upon the current sort order, and what the sort order would be if the user clicked on it again.
The second for loop sorts each of the rows that's contained in the body of the table. A copy of the original tbody is created to store the reordered table rows, and initially this copy is empty. As each row in the original tbody is scanned, the contents of the table cell in the column on which we're sorting is compared with the rows already in the copy.
In order to find the contents of the table cell, we use the function getInternalText:
Example 13.27. sort_tables_by_columns.js (excerpt)
function getInternalText(target)
{
var elementChildren = target.childNodes;
var internalText = "";
for (var i = 0; i < elementChildren.length; i++)
{
if (elementChildren[i].nodeType == 3)
{
if (!/^\s*$/.test(elementChildren[i].nodeValue))
{
internalText += elementChildren[i].nodeValue;
}
}
else
{
internalText += getInternalText(elementChildren[i]);
}
}
return internalText;
}
getInternalText extracts all of the text inside an element -- including all of its descendant elements -- by recursively calling itself for each child element and concatenating the resultant values together. This allows us to access the text inside a table cell, irrespective of whether it's wrapped in elements such as spans, strongs, or ems. Any text nodes that are purely whitespace (spaces, tabs, or new lines) are ignored via a regular expression check.
When sortColumn finds a row in the copy whose sorted table cell value is "less" than the one we're scanning, we insert a copy of the scanned row into the copied tbody. For a column in ascending order, we simply reverse this comparison: the value of the row in the copy must be "greater" than that of the scanned row.
However, before a comparison is made, we check whether the contents of the sorted table cell can be interpreted as an integer or a float; if so, the comparison values are converted. This makes sure that columns that contain numbers are sorted properly; string comparisons will produce different results than number comparisons.
Once all of our original rows have been copied into the new tbody, that element is used to replace the old one, and we have our sorted table!
Using the sortableDescending and sortableAscending classes, which are assigned to the currently sorted table heading cells, we can use CSS to inform the user which column the table is sorted on, and how it is sorted, as shown in Figure 13.2, "A sortable table sorted in descending order on the fourth column" and Figure 13.3, "A sortable table sorted in ascending order on the second column".

Figure 13.2. A sortable table sorted in descending order on the fourth column

Figure 13.3. A sortable table sorted in ascending order on the second column
Summary
The two main pillars of DHTML are the capturing of events, and the reorganization and creation of page elements via the DOM. Using these principles, it's possible to capture many of the different ways that users interact with a page and make the interface respond accordingly.
As can be seen by the number and quality of JavaScript-enhanced web applications that are now available, the features DHTML can bring to new interfaces represents one of the biggest growth areas for innovative JavaScript. The foundations and basic examples shown in this chapter give you a sense of the power that it can deliver inside a user's browser. We'll expand upon this further in the following chapters as we build some really interesting interfaces.
That's it for our sample of The JavaScript Anthology: 101 Essential Tips, Tricks & Hacks. What's next?
Download this sample as a PDF, for reading offline. Check out the book's Table of Contents to see what else it covers. And see what others think of the book -- read live customer reviews of The JavaScript Anthology: 101 Essential Tips, Tricks & Hacks.