ONLamp.com    
 Published on ONLamp.com (http://www.onlamp.com/)
 See this if you're having trouble printing code examples


Advanced JavaScript III

by Howard Feldman
11/20/2007

In this final article of the series, we proceed with another handful of useful JavaScript case studies that focus on manipulating and rewriting the HTML page on-the-fly using Document Object Model (DOM). As always, the JavaScript used in this article should work on all major current browsers without modification.

Magic Mutating with Your Mouse

When creating a form, you may at times wish to have different types of input fields depending on some secondary condition. For example, suppose you are writing a search interface for a library. If someone searches by author, you might have a standard text box. To search by book type (hardcover or softcover, say) you could produce a drop-down list and to search within the books themselves, a large text box. Try it here and it should be clear:

Search for:

The JavaScript to achieve this consists of a callback function, which is triggered whenever the search field selector changes value:


// Copies attributes from one node (src) to another (dest).  Modify this to suit
// your needs.  For example, you may not want to retain the value when a new
// node is generated.
function CopyAttributes(src, dest)
{
  var i;

  dest.id = src.id;
  dest.name = src.name;
}

// Performs the mutation on the node with id given by the argument searchbox.
// opt specifies the type of mutation to make.
function UpdateSearchField(opt, searchbox)
{
  var inputbox = document.getElementById(searchbox);
	
    // Based on search type, choose which element to create and
    // set its attributes accordingly.
	
  if (opt == 'content') { // make a textarea
    var el = document.createElement("TEXTAREA");
    CopyAttributes(inputbox, el);
    el.cols = 40;
    el.rows = 4;
  }
  else if (opt == 'author') { // make a standard text box
    var el = document.createElement("INPUT");
    CopyAttributes(inputbox, el);
    el.type = 'text';
    el.size = 20;
  }
  else if (opt == 'binding') { // make a drop-down list
    var el = document.createElement("SELECT");
    CopyAttributes(inputbox, el);
    el.size = 1;
	
      // str stores the text for the drop-down selector.  Different
      // drop-down lists could be provided by simply switching in
      // different arrays for str.
	
    var i;
    var str = new Array('Hardcover', 'Paperback', 'Magazine');
    for (i = 0; i < str.length; i++) {
      var opt = document.createElement("OPTION");
      opt.appendChild(document.createTextNode(str[i]));
      opt.setAttribute('value', str[i]);
      if (inputbox.value == str[i]) // check for selected item
        opt.setAttribute('selected', 'selected');
      el.appendChild(opt);
    }
  }
	
    // Use the DOM function replaceChild to put in the newly created
    // node.
	
  inputbox.parentNode.replaceChild(el, inputbox);
}

The form is laid out like so:


<form action="" onsubmit="return false;">
  Search for: 
  <select name="searchtype" onchange="UpdateSearchField(this.value, 'searchbox');">
    <option value="author" selected="selected">Author</option>
    <option value="binding">Binding</option>
    <option value="content">Content</option>
  </select>
  <input id="searchbox" type="text" value="" name="searchbox" size="20" />
  <input type="submit" name="submit" value="Go!" />
</form>

The technique is actually quite simple. What happens is that whenever the user changes the value of searchtype (by making a selection from the drop-down list), the onchange attribute calls our JavaScript function, UpdateSearchField, passing in the field that was just selected as well as the id of the search box on the form. This function builds a new piece of HTML and replaces the existing search box with the new one. Effectively, we are rewriting part of the web page on the fly!

It is assumed the reader has some familiarity with HTML DOM. If not, refer to the official documentation here. UpdateSearchField first looks at the field that was selected and then creates a new DOM Element node of the appropriate type. Normally, you would probably want to pass the node type as an argument, or look it up in a table, but for simplicity we just base it on the search field value that was passed in. Now createElement requires some explanation. This function creates a new empty node in the DOM tree of the specified document (document, the current document, in this case). So, for example, creating a new INPUT node would be equivalent to instantiating the HTML <INPUT></INPUT>. Note that while associated with the document, it is not yet actually attached anywhere. Think of it as being stored somewhere offscreen, out of the browser window, allowing us to build and modify it as necessary before bringing it into existence by attaching it somewhere in the current document.

We then proceed to copy over some or all of the attributes from the input box (whose id was passed in as an argument—this is the node we will eventually replace with our new node) to our new node, using the helper function CopyAttributes. You may need to change this to suit your needs. For example you may want to copy the object class or copy over the object value. After copying, attributes specific to the type of node are set.

In the case of the SELECT element, we need to do a bit more work, to create the OPTIONs for it. This is done in a loop, in much the same way as how the other elements are created, using createElement and createTextNode. The latter works just like the former, except instead of creating an HTML tag, it creates a DOM Node containing plain text. This is the text that goes in between the OPTION tags in the HTML. The options are then inserted or attached in between the SELECT tags one at a time using the DOM function appendChild. This process can be better understood by watching what happens step-by-step in the for loop.

Loop IterationHTML generated
0
<SELECT>
</SELECT>
1
<SELECT>
<OPTION value="HardCover">HardCover</OPTION>
</SELECT>
2
<SELECT>
<OPTION value="HardCover">HardCover</OPTION>
<OPTION value="Paperback">Paperback</OPTION>
</SELECT>
3
<SELECT>
<OPTION value="HardCover">HardCover</OPTION>
<OPTION value="Paperback">Paperback</OPTION>
<OPTION value="Magazine">Magazine</OPTION>
</SELECT>

Lastly, now that our new searchbox node is built, we need to instantiate it by attaching it somewhere on the existing page. We also need to delete the current search box. Conveniently, the function replaceChild does just what we need, replacing the existing node (and all of its children, if any) with the new one we have created. Thus the new HTML we have created replaces the HTML block represented by id searchbox. This completes the effect.

This trick is of course not limited to INPUT boxes on search forms, you can in fact mutate ANY element into ANY other element triggered by some event on the page. With a little imagination, many interesting and impressive effects could be achieved using this technique.

Dynamic Tables

It is often desirable in a user interface to have the user input data into a variable number of rows. For example, when filling out an order form, the customer will fill in one item per row. With JavaScript, we no longer have to worry if we put enough space for the order though—let the user add and delete rows as needed! This is demonstrated immediately below, by taking the previous example one step further.

Widgets "R" Us Order Form

Catalog #DescriptionQuantityUnit PriceTotal 
Grand Total:  

To add rows to the end, simply use the Append Row button. To delete a row, click the red X, which will appear at the end of the row you want to delete. You cannot delete when there is only one row left. Also note the Total and Grand Total fields are updated whenever you enter a quantity and price or delete an item from the list. So, how does this all work? We begin by creating an initial table with HTML. It is important that we start with (at least) one row.


<form action="" method="post">
  <table>
    <tr>
      <td colspan="3">
        <table id="catalog" border="1">
          <tr>
            <th>Catalog #</th>
            <th>Description</th>
            <th>Quantity</th>
            <th>Unit Price</th>
            <th>Total</th>
            <th> </th>
          </tr>
          <tr>
            <td>
              <input id="catno_1" name="catno_1" tabindex="1" type="text" value="" />
            </td>
            <td>
              <input id="descr_1" name="descr_1" type="text" value="" />
            </td>
            <td>
              <input id="quant_1" name="quant_1" type="text" value="" onkeyup="UpdateTotals('catalog');" />
            </td>
            <td>
              <input id="price_1" name="price_1" type="text" value="" onkeyup="UpdateTotals('catalog');" />
            </td>
            <td>
              <input id="total_1" name="total_1" type="text" value="" disabled="disabled" />
            </td>
            <td>
              <span id="delete_1" style="color:red; cursor: pointer;" onclick="DeleteRow(this);"> </span>
            </td>
          </tr>
        </table>
      </td>
    </tr>
    <tr>
      <td>
        <input type="submit" name="submit" value="Append Row" onclick="AppendRow('catalog'); return false;"/>
      </td>
      <td align="right">
        <b>Grand Total:</b>
        <input id="total" name="total" type="text" value="" disabled="disabled" />
      </td>
      <td>
        &nbsp;
      </td>
    </tr>
  </table>
</form>

This is just a standard HTML table surrounded by a form, with input tags in each cell where the user can input data. Note that the name and id of each input tag, as well as the id of the delete button, are a string followed by _1,. This is used to both ensure uniqueness, and to keep track of what row each item belongs to—in this case, row 1. So, for example, the price column on row 3 would have a name and id of price_3, and so forth. The Total and Grand Total fields are disabled since they are calculated fields and are read-only. Everything else is taken care of by three JavaScript functions: AppendRow attached to the button of the same name, DeleteRow attached to the delete X, and UpdateTotals, which is triggered whenever a key is pressed in the Quantity or Price fields, to update the totals. Let's examine this one first:


// Reads the quantity and price columns in the form and computes the
// totals and grand total, filling these in.
function UpdateTotals(table_id)
{
  var numrows = document.getElementById(table_id).rows.length - 1;  // don't count the header row!
  var i, totalcost = 0.00;
  for (i = 1; i <= numrows; i++) {
  
      // Compute total for each row
  
    var q = parseInt(document.getElementById('quant_' + i).value);
    var price = parseFloat(document.getElementById('price_' + i).value);
    var cost;
    if (!q || !price)
      cost = 0.00;
    else
      cost = q * price;
    var total = document.getElementById('total_' + i);
    total.value = '$' + cost;
    totalcost = totalcost + cost;  // Keep running grand total
  }
  var total = document.getElementById('total');
  total.value = '$' + totalcost;
}

This function takes the id of the table as its only argument and computes the total and grand total for the form. It computes the number of rows and loops over them. Note that row 0 is the header row of the table, and so we skip this and start at row 1. It reads the quantity and price information for each line, taking advantage of the naming scheme of the ids for these columns, then computes the total by multiplying them if both non-zero. The result is then stored in the Total column for that row. The grand total is also computed and stored in the Grand Total field. Note we can poke in values to these just fine using JavaScript, even though they are marked as disabled. Now let us examine the AppendRow function:


// Appends a row to the given table, at the bottom of the table.
function AppendRow(table_id)
{
  var row = document.getElementById(table_id).rows.item(1);  // 1st row
  var newid = row.parentNode.rows.length;  // Since this includes the header row, we don't need to add one

  var newrow = row.cloneNode(true);
  rowrenumber(newrow, newid);
  row.parentNode.appendChild(newrow);      // Attach to table
    
    // Clear out data from new row.
	
  var curnode = document.getElementById('catno_' + newid);
  curnode.value = "";
  curnode.tabIndex = newid;
  curnode = document.getElementById('descr_' + newid);
  curnode.value = "";
  curnode = document.getElementById('quant_' + newid);
  curnode.value = "";
  curnode = document.getElementById('price_' + newid);
  curnode.value = "";
  curnode = document.getElementById('total_' + newid);
  curnode.value = "";
  curnode = document.getElementById('delete_' + newid);
  curnode.innerHTML = "X";
  curnode = document.getElementById('delete_1');  // Really only need this when newid = 2
  curnode.innerHTML = "X";
}

Again, the only argument is the id of the table we are appending to. We begin by getting a handle to the first tr of the table in the DOM tree in the variable row and then checking its parent node to get the total number of rows in the table. Then we use the DOM function cloneNode to make a new copy of the row. The argument to this function indicates we want to include all children as well, so we get the whole row copied and not just an empty tr node. A helper function, rowrenumber, is used to change all the id tags to correspond to the new row number (which is the number of input rows in the form, plus one). The DOM function appendChild is then used to attach the new row to the table. Lastly, the input fields are all blanked out since they were copied over from the first row—we don't want to copy the data, just the structure. An X is placed in the delete column of row 1, as well as the new row, in case it was not there before (it is replaced by a blank space when there is only one row, so the user will not try to delete in this case). Let's have a brief look at the function ro renumber the the ids:


// Given a tr node and row number (newid), this iterates over the row in the
// DOM tree, changing the id attribute to refer to the new row number.
function rowrenumber(newrow, newid)
{
  var curnode = newrow.firstChild;      // td node
  while (curnode) {
    var curitem = curnode.firstChild;   // input node (or whatever)
    while (curitem) {    
      if (curitem.id) {  // replace row number in id
        var idx = 0;
        var spl = curitem.id.split('_');
        var baseid = spl[0];
        curitem.id = baseid + '_' + newid;
        if (curitem.name)
          curitem.name = baseid + '_' + newid;
        if (baseid == 'catno')
          curitem.tabIndex = newid;
      }
      curitem = curitem.nextSibling;
    }
    curnode = curnode.nextSibling;
  }
}

This function traverses the DOM tree starting from the tr node passed in in newrow. It does a two-level depth-first traversal of the tree, looking for any node with a non-empty id field. We only go two levels deep (i.e., two nested for loops) because we know in our table this is where the ids are. The first level in are the td tags, and the second level down are the input tags where all our ids of interest are. Since we took special care to make all our ids of the form {type}_{rownumber}, we can take advantage of this now and use the Javascript split function on the string to break it up, and replace the old row number with the new one. We update the name field at the same time, if it exists, as well as tabIndex for the first column.

The final piece of the form is the delete row function:


// Give a node within a row of the table (one level down from the td node),
// this deletes that row, renumbers the other rows accordingly, updates
// the Grand Total, and hides the delete button if there is only one row
// left.
function DeleteRow(el)
{
  var row = el.parentNode.parentNode;   // tr node
  var rownum = row.rowIndex;            // row to delete
  var tbody = row.parentNode;           // tbody node
  var numrows = tbody.rows.length - 1;  // don't count header row!
  if (numrows == 1)                     // can't delete when only one row left
    return false;

  var node = row;
  tbody.removeChild(node);
  var newid = -1;
  
    // Loop through tr nodes and renumber - only rows numbered
    // higher than the row we just deleted need renumbering.
  
  row = tbody.firstChild;
  while (row) {
    if (row.tagName == 'TR') {
      newid++;
      if (newid >= rownum)
        rowrenumber(row, newid);
    }
    row = row.nextSibling;
  }
  if (numrows == 2) {  // 2 rows before deleting - only 1 left now, so 'hide' delete button
    var delbutton = document.getElementById('delete_1');
    delbutton.innerHTML = ' ';
  }
  UpdateTotals(tbody.parentNode.id);     // Grand Total may change after a delete, so update it
}

In the HTML, this function is called from the span tag passing this as an argument. Hence coming in to the function, el is a pointer to the span tag that was clicked, and we must go up two levels in the DOM tree to reach the tr node. We could also have just passed in this.parentNode.parentNode as the argument, instead. First we make sure there are at least two rows since we do not allow deleting when only one row remains. Then we proceed to remove the row node using the DOM function removeChild. Note that it is called as a method of the parent tbody node.

Next we loop through the tree for the table stopping at each tr node. The rows after the one we deleted, if any, now need to be renumbered since their row number has decreased by one (for example, deleting row four means row five becomes row four, row six bcomes row five, and so on, while rows one, two, and three remain unchanged). To do this we use our handy rowrenumber function that we have already discussed above. Then we stop and check if there is only one row left (i.e., two before the deletion). If so, we hide the delete button on row one by replacing the X with a space so the user will not try to click it (but if he manages to somehow, nothing will happen anyways since we check for this case above). Lastly, deleting a row means the Grand Total will probably change, unless the row was blank, so we call UpdateTotals to ensure the math remains consistent.

This is probably the most complex example we have seen so far, but is really quite simple when you break it down into its component parts. This example makes extensive use of DOM functions and the DOM tree, and hopefully gives a taste of the power at your disposal permitting dynamic manipulation of the HTML page. As an exercise, try adding another column with an Insert Row button on each row, which inserts a blank row before the row on which the button was clicked. Hint: you should be able to do this by making only minor modifications to the AppendRow function.

Elusive Text

In this final example, again using DOM, we examine a simple method for providing temporary text in an input field, which disappears when the field is not completely empty. This could be used to save space, for example, by putting the field headings directly in the input box instead of to the left or above. Here is a working example of what we mean:

Enter Login Name
  
Enter Password
  

The HTML to create this sample login page is as follows:

<style type="text/css">
.helptext {
  position: absolute;
  color: #999999;
  z-index: 10;
  font-size: small;
  overflow: hidden;
}
</style>

<form action="" method="post">
  <input type="text" name="login" value="" onkeyup="UpdateHelpText(this);" onmouseup="UpdateHelpText(this);" />
  <div class="helptext" id="label_login" onclick="ChangeFocus(this);">
    Enter Login Name
  </div>
  &nbsp;&nbsp;
  <input type="password" name="password" value="" onkeyup="UpdateHelpText(this);" onmouseup="UpdateHelpText(this);" />
  <div class="helptext" id="label_password" onclick="ChangeFocus(this);">
    Enter Password
  </div>
  &nbsp;&nbsp;
  <input type="submit" name="submit" value="Login" onclick="return false;" />
</form>

<script type="text/javascript" language="JavaScript">
  UpdateHelpText(document.getElementsByName('login')[0]);
  UpdateHelpText(document.getElementsByName('password')[0]);
</script>

We begin by defining a CSS style for our floating text. Absolute positioning is used to allow the text to sit on top of other elements on the page (the input box in this case). Similarly z-index is set high to ensure the text appears on top and not behind other elements. overflow is set to hidden, as we do not want scroll bars ever appearing around the text. If it should not fit into its alotted space it will just be truncated.

The form is a standard form with hooks to a function UpdateHelpText when a key is pressed and released, or the mouse is released, in the input area. This function, as we shall see, serves to hide or show the text depending on whether the input area is empty. Thus, whenever we start typing in an input box, the floating text will vanish. When we completely delete its contents, it will magically reappear. We call this function just after the form, to initialize the text and correctly position it within the form. Following each input tag is a div tag containing the floating text itself, making use of the helptext CSS class we created above. Note that because we set the z-index high so the text floats on top, we have a problem in that clicking on the text selects the text instead of moving the cursor to the input area, which in all likelihood is what the user wants. To correct this little problem, we call a function ChangeFocus when the mouse is clicked on the help text. Let's look at the two JavaScript functions now.


// Look for previous INPUT tag and move cursor there.
function ChangeFocus(el)
{
  while (el.tagName != 'INPUT') 
    el = el.previousSibling;
  el.focus();
}

// Turn on or off help text depending on content of INPUT field.
function UpdateHelpText(el)
{
  var label = el;
  while (label.tagName != 'DIV')
    label = label.nextSibling;
  if (el.value == '') {  // Field is empty; show text
    label.style.left = getElementAbsPosX(el) + 'px';
    label.style.top = (getElementAbsPosY(el) - 7) + 'px';
    label.style.visibility = 'visible';
  }
  else { // Field is not empty; hide text
    if (label)
      label.style.visibility = 'hidden';
  }
}

ChangeFocus is quite simple. It simply starts at the current node (the div tag) and goes backwards in the DOM tree until the first input tag it finds. Calling focus on the input tag sends the text cursor there, as if the user had clicked directly in the input area. The result is that now clicking on the floating text is effectively the same as clicking directly in the input area. Note we could have used ids to link the div tag to its corresponsing input tag - for example give the input tag id login and the div tag id login_label, allowing us to easily get from one node to the other given its id—but instead we are using our knowledge of the structure of this HTML page to find the node. If we used ids, no knowledge or assumptions about the page structure would be necessary, so it is probably a better approach for general application.

UpdateHelpText finds the div tag starting from the input tag in exactly the same way. If the input field contains text the floating text is hidden using the visibility CSS property. Otherwise this property is set to visible to make the text appear. The text is also positioned just inside the input field every time this function is called. Thus if you change your browser text size or magnification, for example, just clicking in the input box or pressing a key should re-locate the text back to its proper position, if not already there. The positioning functions getElementAbsPosX and getElementAbsPosY simply get the position in absolute pixels of the input element so the floating text can be placed correctly. For their source and a discussion on how they work, please see the first article in this series.

Summary

In this installment we have explored some of the tricks made possible by manipulating the DOM using JavaScript. With these techniques, one can modify any part of an HTML page, adding content that was not there when the page loaded, deleting bits, or even shuffling them around! These examples have only scratched the surface of what might be accomplished with a little imagination. It is important to point out that although most browsers work quite well at allowing you to, essentially, rewrite HTML on the fly inside computer memory, things can start to get a little hairy if you make a lot of changes and some browsers can handle this better than others. Especially things like trying to create new nodes with callback functions attached that take variable arguments can be tricky, as this is handled quite differently between Internet Explorer and Mozilla-family browsers. I hope you have found these articles educational and that they have inspired you to try some new ideas to spice up your own web pages with a little bit of advanced JavaScript.

Howard Feldman is a research scientist at the Chemical Computing Group in Montreal, Quebec.


Return to ONLamp.

Copyright © 2009 O'Reilly Media, Inc.