jQuery Plugin insertAt() For Comparator-Based Insertion
When creating or augmenting user interfaces with JavaScript, I often find myself in a situation where I have to insert a new DOM element into a collection of existing DOM elements based on some sort of comparison. Sometimes the target of this comparison is the text() value of the given nodes; more often it's a custom data attribute. In either case, I have to iterate over the target collection in order to determine where the new DOM element needs to be inserted. This task always feels more complicated than it needs to be; so, I thought I might try writing a jQuery plugin to help simplify the matter.
The plugin that I wrote has two components: one in the core jQuery namespace and one in the fn (prototype) namespace. These two aspects of the plugin work in conjunction with each other, specializing in different use-cases. Let's look at the core namespace first:
jQuery.insertAt( item, collection, comparator ) :: newIndex
This takes a single item, the collection to be inspected, and the comparator callback used to determine where (if at all) the given item should be inserted into the given collection. If an insert takes place, insertAt() returns the index of the newly inserted item (relative to the collection). If no insert takes place, insertAt() returns (-1).
The comparator() is a callback whose return value dictates the item insertion. It is expected to have the following signature:
comparator( item, targetItem, isLastTargetItem, index ) :: -1 || 1
Both the item and the targetItem are jQuery collections (ie. not raw DOM nodes). The isLastTargetItem is a boolean value meant to indicate whether or not the given targetItem is the last item in the current collection. This allows additional logic to be provided for situations in which the new item should be appended to the end of the collection.
If the comparator() returns a (-1), the new item will be inserted before the target item. If the comparator() returns a (1), the new item will be inserted after the target item.
The insertAt() method in the core namespace works for a single item. At the fn namespace level, the insertAt() plugin can work with a collection of new items (with method chaining kept in tact). Since this aspect of the plugin deals with collections, the insertAt() signature is a bit different:
jQuery.fn.insertAt( collection, comparator ) :: [this]
This part of the plugin is actually a lot more interesting. Internally, it uses the jQuery.insertAt() version to perform the inserts; but, after each insert is performed, the fn-based plugin has to augment the internal target collection so as to make sure that each insert properly influences subsequent inserts within the same collection iteration.
Let's take a look at the insertAt() plugin in action. In this demo, we're going to take a list of friend's names and augment it:
<!DOCTYPE html>
<html>
<head>
<title>jQuery InsertAt() Plugin</title>
<script type="text/javascript" src="./jquery-1.6.1.js"></script>
<script type="text/javascript">
// I insert the given item into the given collection based on
// the results of the comparator return value:
//
// -1 : Item inserted BEFORE given target.
// 1 : Item inserted AFTER given target.
//
// Only one insert will be performed. The index of the new
// location of the given item will be returned (or -1 if the
// item was not inserted at all).
//
// The comparator() method is expected to have the following
// signature:
//
// - item
// - targetItem
// - isLastItemInCollection
//
// A third argument (isLastItemInCollection) is provided in
// case additional logic is required based on no previously
// useful matches.
jQuery.insertAt = function( item, collection, comparator ){
// Make sure the item is a jquery collection.
item = $( item );
// Loop over the collection to compare the item to each
// target within the collection.
for (var i = 0 ; i < collection.length ; i++){
// Compare the given item to the given target.
// Convert each item to a jQuery collection as a
// convenience to the callback.
var insertDirective = comparator.call(
collection[ i ],
item,
jQuery( collection[ i ] ),
(i == (collection.length - 1)),
i
);
// Check for any pre-insert.
if (insertDirective === -1){
// Insert before the target.
item.insertBefore( collection[ i ] );
// The given item will take the place of the
// target (as far as index goes).
return( i );
} else if (insertDirective === 1){
// Insert after the target.
item.insertAfter( collection[ i ] );
// The given item will take the place of the
// next target (as far as index goes).
return( i + 1 );
}
}
// If we made it this far then no insertion has taken
// place.
return( -1 );
};
// -------------------------------------------------- //
// -------------------------------------------------- //
// I insert the given elements in the current collection
// into the target location of the given collection based
// on the comparator return value:
//
// -1 : Item inserted BEFORE given target.
// 1 : Item inserted AFTER given target.
jQuery.fn.insertAt = function( collection, comparator ){
// Loop over each item in the current collection -
// each one will be inserted into the target collection
// (possibly).
this.each(
function( index, item ){
// Insert this element into the collection and
// get the index of the inserted item (returns
// -1 if no insert took place).
var newIndex = jQuery.insertAt(
item,
collection,
comparator
);
// Check to see if an insert took place. If it
// did, we need to update the INTERNAL collection
// so that subsequent iterations will take the
// newly created elements into account.
if (newIndex === collection.length){
// Simply add the item to the of the
// colleciton. This will create a new, non-
// destructive collection.
collection = collection.add( item );
// The item was inserted into the collection
// somwhere other than at the end.
} else if (newIndex !== -1){
// Overwrite the collection, adding the new
// item to the group. This will create a new,
// non-destructive collection.
collection = collection.map(
function( index, node ){
// Check to see if this is the index
// being replaced by the new item.
if (newIndex === index){
// Return the new item inline with
// the given node. This will get
// flattened in the merge.
return( [ item, node ] );
} else {
// Return the given node - we
// aren't inserting anything yet.
return( node );
}
}
);
}
}
);
// Return the current jQuery collection.
return( this );
};
</script>
</head>
<body>
<h1>
jQuery InsertAt() Plugin
</h1>
<ul class="friends">
<li>Anna</li>
<li>Joanna</li>
<li>Kim</li>
<li>Sarah</li>
</ul>
<!-- Once the page has loaded, run the scripts. -->
<script type="text/javascript">
// Get a reference to the current list of friends.
var friends = $( "ul.friends" );
// Create a new friend item.
var friend = $( "<li>Nancy</li>" );
// Insert the friend element into the existing list.
friend.insertAt(
friends.children(),
function( item, target, isLast ){
// Check to see if the friend should be inserted
// based on the text value of the current nodes.
if (item.text() < target.text()){
// Insert the new friend before the given friend.
return( -1 );
// Check to see if this is the last comparison.
} else if (isLast){
// If the friend didn't fit anywhere else in the
// target list, then just append it to the end.
return( 1 );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// Create a whole collection of new friends.
var newFriends = $(
"<li>Esty</li>" +
"<li>Emma</li>" +
"<li>Tricia</li>" +
"<li>Veronica</li>"
);
// Insert each new friend into the list.
newFriends.insertAt(
friends.children(),
function( item, target, isLast ){
// Check to see if the friend should be inserted
// based on the text value of the current nodes.
if (item.text() < target.text()){
// Insert the new friend before the given friend.
return( -1 );
// Check to see if this is the last comparison.
} else if (isLast){
// If the friend didn't fit anywhere else in the
// target list, then just append it to the end.
return( 1 );
}
}
);
</script>
</body>
</html>
Here, we start out with the following list of friend's names:
Anna
Joanna
Kim
Sarah
... and end up with this list, kept in alphabetical order:
Anna
Emma
Esty
Joanna
Kim
Nancy
Sarah
Tricia
Veronica
jQuery already has great functionality for inserting DOM elements before or after other DOM elements; but those only work when you know exactly where things need to go ahead of time. When augmenting user interfaces that have some sort of inherent order (typically alphabetical), you have to first find the relevant elements and then execute the DOM manipulation. The insertAt() plugin is meant to help condense these two steps into one, simplified method.
Want to use code from this post? Check out the license.
Reader Comments
The timing on this post is insanely eerie, I was going to have to write a plugin that does this today, for a friends list on a web socket chat.
Must be my lucky day that you did all the hard work for me ;)
Good work.
@Jeremy,
Ha ha ha, awesome :) Great minds clearly think alike ;)
if i had to make something like this, the interface would be collection.insertAt( item, comparator ).
@Nelle,
It's funny you bring that up; jQuery typically provides bi-directional ways to insert things like:
insertBefore() vs. before()
insertAfter() vs. after()
I thought about doing something like that; but honestly, I just couldn't think of a good name for it :) Coming up with insertAt() was pretty exhausting in and of itself.
I like it--the solution. I recently did something like this for work, and I have a question. If you were developing something like this, but you needed to provide an alternate method for your users that did not have javascript enabled, what would you do? Would you put a noscript tag on the page and put the noscript code in the noscript tag, or would you put a link there saying that you needed javascript to appreciate the full functionality of the site, but if you absolutely had to have javascript turned off, click here, and that link would lead to a page where a user could enter the information using ColdFusion and a much less dynamic form? (and on that, would you allow it to be dynamic by having "page refreshes", or would you just simplly let your user know that without javascript it woudn't be dynamic?). I didn't encounter this, as I was developing for a company that mandates our users view in a certain environment (using explorer with javascript turned on), but for the sake of argument, is this how you would do something like that if you had to have the non-javascript version of it also? Just curious. :-)
@Anna,
From what I've been told, best-practice for sites that are going to be non-JavaScript compatible, is to build it for static HTML first; then, use JavaScript to "enhance" the page after it has loaded (ie. progressive enhancement). This way, the user experience is "seamless" regardless of whether or not JavaScript is enabled.
This is a great plugin. Just one small addition - to make it also work when the container element is initially empty (such as a UL element with no LI elements in it), make the following change.
In the jQuery.insertSorted function, just below the line:
item = $( item );
Add the following code:
if (collection.length==0) {
collection.prevObject.append(item);
return 0;
}
Thanks for the article it is sometimes hard to keep up with all the changes that get made. Your blog makes it easier for that to happen.