20090605 Friday June 05, 2009

Creating a Facebook-style Autocomplete with GWT Have you used the "To:" widget on on Facebook or LinkedIn when composing a message? It's an autocompleter that looks up contact names and displays them as you type. It looks like a normal textbox (a.k.a. <input type="text">), but wraps the contact name to allow you to easily delete it. Here's a screenshot of what Facebook's widget looks like.

Facebook Autocomplete

Last week, I was asked to create a similar widget with GWT. After searching the web and not finding much, I decided to try writing my own. The best example I found on how to create this widget was from James Smith's Tokenizing Autocomplete jQuery Plugin. I used its demo to help me learn how the DOM changed after you selected a contact.

GWT's SelectBox allows you to easily create an autocompleter. However, it doesn't have support for multiple values (for example, a comma-delimited list). The good news is it's not difficult to add this functionality using Viktor Zaprudnev's HowTo. Another feature you might want in a SelectBox is to populate it with POJOs. GWT SuggestBox backed by DTO Model is a good blog post that shows how to do this.

Back to the Facebook Autocompleter. To demonstrate how to create this widget in GWT, I put together a simple application. You can view the demo or download it. The meat of this example is in an InputListWidget. After looking at the jQuery example, I learned the widget was a <div> with a unordered list (<ul>). It starts out looking like this:

<ul class="token-input-list-facebook">
    <li class="token-input-input-token-facebook">
        <input type="text" style="outline-color: -moz-use-text-color; outline-style: none; outline-width: medium;"/>
    </li>
</ul>

I did this in GWT using custom BulletList and ListItem widgets (contained in the download).

final BulletList list = new BulletList();
list.setStyleName("token-input-list-facebook");

final ListItem item = new ListItem();
item.setStyleName("token-input-input-token-facebook");

final TextBox itemBox = new TextBox();
itemBox.getElement().setAttribute("style", 
        "outline-color: -moz-use-text-color; outline-style: none; outline-width: medium;");

final SuggestBox box = new SuggestBox(getSuggestions(), itemBox);
box.getElement().setId("suggestion_box");

item.add(box);
list.add(item);

After tabbing off the input, I noticed that it was removed and replaced with a <p> around the value and a <span> to show the "x" to delete it. After adding a couple items, the HTML is as follows:

<ul class="token-input-list-facebook">
    <li class="token-input-token-facebook">
        <p>What's New Scooby-Doo?</p>
        <span class="token-input-delete-token-facebook">x</span>
    </li>
    <li class="token-input-token-facebook">
        <p>Fear Factor</p>
        <span class="token-input-delete-token-facebook">x</span>
     </li>
     <li class="token-input-input-token-facebook">
         <input type="text" style="outline-color: -moz-use-text-color; outline-style: none; outline-width: medium;"/>
     </li>
</ul>

To do this, I created a deselectItem() method that triggers the DOM transformation.

private void deselectItem(final TextBox itemBox, final BulletList list) {
    if (itemBox.getValue() != null && !"".equals(itemBox.getValue().trim())) {
        /** Change to the following structure:
         * <li class="token-input-token-facebook">
         * <p>What's New Scooby-Doo?</p>
         * <span class="token-input-delete-token-facebook">x</span>
         * </li>
         */

        final ListItem displayItem = new ListItem();
        displayItem.setStyleName("token-input-token-facebook");
        Paragraph p = new Paragraph(itemBox.getValue());

        displayItem.addClickHandler(new ClickHandler() {
            public void onClick(ClickEvent clickEvent) {
                displayItem.addStyleName("token-input-selected-token-facebook");
            }
        });

        Span span = new Span("x");
        span.addClickHandler(new ClickHandler() {
            public void onClick(ClickEvent clickEvent) {
                list.remove(displayItem);
            }
        });

        displayItem.add(p);
        displayItem.add(span);
        
        list.insert(displayItem, list.getWidgetCount() - 1);
        itemBox.setValue("");
        itemBox.setFocus(true);
    }
}

This method is called after selecting a new item from the SuggestBox:

box.addSelectionHandler(new SelectionHandler() {
    public void onSelection(SelectionEvent selectionEvent) {
        deselectItem(itemBox, list);
    }
});

I also added the ability for you to type in an e-mail address manually and to delete the previous item when you backspace from the input field. Here's the handler that calls deselectItem() and allows deleting with backspace:

// this needs to be on the itemBox rather than box, or backspace will get executed twice
itemBox.addKeyDownHandler(new KeyDownHandler() {
    public void onKeyDown(KeyDownEvent event) {
        if (event.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
            // only allow manual entries with @ signs (assumed email addresses)
            if (itemBox.getValue().contains("@"))
                deselectItem(itemBox, list);
        }
        // handle backspace
        if (event.getNativeKeyCode() == KeyCodes.KEY_BACKSPACE) {
            if ("".equals(itemBox.getValue().trim())) {
                ListItem li = (ListItem) list.getWidget(list.getWidgetCount() - 2);
                Paragraph p = (Paragraph) li.getWidget(0);

                list.remove(li);
                itemBox.setFocus(true);
            }
        }
    }
});

I'm happy with the results, and grateful for the jQuery plugin's CSS. However, it still has one issue that I haven't been able to solve: I'm unable to click on a list item (to select it) and then delete it (with the backspace key). I believe this is because I'm unable to give focus to the list item. Here's the code that highlights the item and you can see the commented-out code that doesn't work.

displayItem.addClickHandler(new ClickHandler() {
    public void onClick(ClickEvent clickEvent) {
        displayItem.addStyleName("token-input-selected-token-facebook");
    }
});

/** TODO: Figure out how to select item and allow deleting with backspace key
displayItem.addKeyDownHandler(new KeyDownHandler() {
    public void onKeyDown(KeyDownEvent event) {
        if (event.getNativeKeyCode() == KeyCodes.KEY_BACKSPACE) {
            list.remove(displayItem);
        }
    }
});
displayItem.addBlurHandler(new BlurHandler() {
    public void onBlur(BlurEvent blurEvent) {
        displayItem.removeStyleName("token-input-selected-token-facebook");
    }
});
*/

If you know of a solution to this issue, please let me know. Feel free to use this widget and improve it as you see fit. I'd love to see this as a native widget in GWT. In the meantime, here's the GWT Facebook-style Autocomplete demo and code. Posted in Java at Jun 05 2009, 07:05:10 AM MDT 14 Comments

Comments:

Imagine my surprise when I open up my RSS feed and see my name! haha! Ironically, I'll even be flying out to Denver today. We had used Yahoo!'s autocomplete widget in our portal to do multi-valued lists in our email portlet. Not as fancy looking as Facebooks, but it had the same functionality as GMails.

Posted by Scott Battaglia on June 05, 2009 at 07:42 AM MDT #

He he. That's what you get for being in my Facebook Friends. ;-)

On a related note, the list of contacts in the GWT demo is a random assortment of folks I exported from my LinkedIn Connections.

Posted by Matt Raible on June 05, 2009 at 10:45 AM MDT #

Looks well done

Posted by Pace on June 07, 2009 at 08:40 PM MDT #

From what I have read I thin you need some hidden input element to capture focus on, that way you can listen for those keydown events. Another approach would be to try to create a Composite and use "sinkEvents" and "onBrowserEvent", but I dont know whether this works without real focus.

Posted by Sakuraba on June 08, 2009 at 02:16 AM MDT #

Hello, this component is great but I've got a problem. How can I get all the elements of the autosuggest box?. I can't find a method to get one-by-one all the text that the user has selected. Is it possible?. Thanks a lot. Regards, Iván.

Posted by Iván on June 11, 2009 at 04:46 PM MDT #

Thanks for the link back to my blog ;)

Posted by James Heggs on June 18, 2009 at 06:19 AM MDT #

@Iván - if you download the source code for this project, you'll see there's logic to save the chosen elements in an "itemsSelected" list. I didn't show the logic in this blog post to make things simpler.

Posted by Matt Raible on June 18, 2009 at 07:26 AM MDT #

With IE it would seem that because the input field is smaller than the list width sometimes clicking on the whitespace doesn't focus the input field, the problem is alleviated by setting the width of the input element to 100%, mozilla and chrome don't suffer this problem.

Strangely, my first idea, adding a HasMouseDownHandler to the BulletList and adding a handler that focuses the input box didn't work.

For all the poor souls that need to send that data from a form, you can generate (or wrap) in parallel a hidden ListBox, just don't forget to select the items :) Of course, what to show the user if form fails validation server side is another issue, fix one problem, open the next :D

Also, you use the text in the input filed as the selected value, however, if one uses the Suggestions+DTO value, perhaps it's better to show the ReplacementString and save the DTO value in itemsSelected but that would require some further code changing.

I can't see if there is a way to make suggestions on more than 1-row? Anyhow, great job!

Posted by Srgjan Srepfler on June 19, 2009 at 04:56 PM MDT #

In order to have the multiple rows, all it takes is to override isDisplayStringHTML in the concrete Oracle to return true and you can put markup as desired in the Suggestion.

Posted by Srgjan Srepfler on June 22, 2009 at 03:44 AM MDT #

Hello,

thanks for your reply. The first thing I did (before posted my first comment) was download the source code and integrate it into my project. Instead of having a panel I changed some code to simply have a textbox to put into my panels. Then I change the code to fill the oracle with the information from my database and it works ok.

Then, I needed to set in the suggestTextBox the data that I read from the database. After debuging and trying to understand the code, I created the following method to set the values I read from the database. That was easy.

	public void setItem(String item) {
		itemBox.setValue(item);
		deselectItem(itemBox, list);
	}

The most complicated part to me was reset the values by code. I needed to simulate the behaviour produced when the user clicks on the "x" button in the element. After a lot of test and debuging I created the following method:

	public void removeItems() {
		String tagToRemove;
		int num = list.getWidgetCount();
		for (int i=num-1; i>=0; i--) {
			tagToRemove = ((ListItem)list.getWidget(i)).getText();
			if (list.getWidget(i).getElement().getInnerHTML().contains("<span>x</span>")) {
				itemsSelected.remove(tagToRemove.substring(0, tagToRemove.length()-1));
				list.remove(list.getWidget(i));
			}
		}
	}

And now, finally it works!!.

This component is amazing and it is what I've been looking for a lot of time. The best for me would be something like the labels management in gmail, but with this I'm happy.

Now it is integrated in Mufly (my GWT personal project). There is no demo to try it but it will be available in few days.

Regards, Iván.

Posted by Iván on June 23, 2009 at 08:30 AM MDT #

a small problem, if the itemsSelected.size() is zero, should stop going on, otherwise there will be an exception

// handle backspace
if (event.getNativeKeyCode() == KeyCodes.KEY_BACKSPACE) {
	if ("".equals(itemBox.getValue().trim())) {
		// if the itemsSelected.size() is zero, stop going on, otherwise there 
		// will be an exception
		if (itemsSelected.size() == 0) {
			return;
		}
		ListItem li = (ListItem) list.getWidget(list.getWidgetCount() - 2);
		Paragraph p = (Paragraph) li.getWidget(0);
		if (itemsSelected.contains(p.getText())) {
			itemsSelected.remove(p.getText());
			GWT.log("Removing selected item '"
					+ p.getText() + "'", null);
			GWT.log("Remaining: " + itemsSelected, null);
		}
		list.remove(li);
		itemBox.setFocus(true);
	}
}

Posted by Vincent Song on July 06, 2009 at 03:21 AM MDT #

Did anyone figure out is it possible to select and backspace-to-delete? Also, how about re-ordering?

Posted by Srgjan Srepfler on July 06, 2009 at 04:05 AM MDT #

Admittedly, I haven't read this too closely, but to solve the click + backspace, I'd suggest making each name in the list a span. Add an onclick handler that marks it (perhaps by adding a css class "selected"). Each time you click it toggles whether the "selected" class is added. You could click multiple names and have multiple spans marked as selected. If you click a particular name again, it would remove the selected class. That's a very simple function to write.

At that point, pressing "delete" makes sense -- and you just remove all the ones that have the "selected" class. I personally wouldn't think to use backspace except if my blinking indicator were at the end of the line (I'd expect it to delete the last item). But if you wanted, you could have "backspace" delete the selected items, as well.

Posted by Richard Morgan on July 21, 2009 at 07:47 AM MDT #

Hello, you ve done a very nice work, very educational. i love it.

Posted by mumuri on January 29, 2010 at 02:50 PM MST #

Post a Comment:
  • HTML Syntax: Allowed