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 25 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 03:50 PM MST #

hello, I was checking the code but I was unable to build it with maven does any one has the eclipse project for this? regards

Posted by 201.240.70.192 on August 17, 2010 at 03:53 PM MDT #

Well for those that does not know how to use maven, you just need to add to the project the main/java/org/appfuse part, I hope it help, it works for me regards

Posted by Sebastian on August 17, 2010 at 06:58 PM MDT #

Thanks for the code! I don't know if you consider this but having a focus panel (FocusPanel) around your paragraph in this case can give you all the handlers you need: Focus, Blur, KeyDown, etc. That will give you the access to backspace-to-delete thing...
my 2 cents!
--javo

Posted by Javier Ochoa on November 17, 2010 at 08:21 AM MST #

Hi Matt,

Could you update a link in your blog post for me please?

The link 'GWT SuggestBox backed by DTO model' is to my old blog on Blogger.

If possible could you update it to my new blog at:

http://eggsylife.co.uk/2008/08/25/gwt-suggestbox-backed-by-dto-model/

Posted by James Heggs on November 17, 2010 at 08:26 AM MST #

@James - I've updated the link. Thanks for letting me know!

Posted by Matt Raible on November 23, 2010 at 11:18 AM MST #

Has anyone tried this with GWT 2.4.0? My suggestions are loaded fine by a custom oracle but are not rendered visible. When typing a suggestion and press enter, the resulting suggestion in the widget is always the first, even if it does not match what was typed.

Posted by Manos on January 29, 2012 at 10:06 PM MST #

Turns out i had a few issues, this was mainly a CSS one; the suggest box was not visible because it had a low z-index.

Posted by Manos on January 31, 2012 at 06:29 PM MST #

Is this open source or whats the licence to use it?

I am looking for a Vaadin Widget like this and was considering wrapping this example to support Vaadin.

Thanks
Lars.

Posted by Lars on February 27, 2012 at 06:35 AM MST #

Lars - the license is Apache. Feel free to use it however you like.

Posted by Matt Raible on February 27, 2012 at 06:38 AM MST #

First of all, thank you Matt for sharing your knowledge, experience and great code!

I have played around with and tested the code today and seem to have the following functionality working:

  • de-highlighting items
  • deleting highlighted items via delete or backspace keys

Here is the code with the minor changes needed:

public class MultiValueSuggestBox extends Composite {
    List<String> itemsSelected = new ArrayList<String>();
    List<ListItem> itemsHighlighted = new ArrayList<ListItem>();

    public MultiValueSuggestBox(MultiWordSuggestOracle suggestions) {
        FlowPanel panel = new FlowPanel();
        initWidget(panel);
        // 2. Show the following element structure and set the last <div> to
        // display: block
        /*
         * <ul class="multiValueSuggestBox-list"> <li
         * class="multiValueSuggestBox-input-token"> <input type="text" style=
         * "outline-color: -moz-use-text-color; outline-style: none; outline-width: medium;"
         * /> </li> </ul> <div class="multiValueSuggestBox-dropdown"
         * style="display: none;"/>
         */
        final BulletList list = new BulletList();
        list.setStyleName("multiValueSuggestBox-list");
        final ListItem item = new ListItem();
        item.setStyleName("multiValueSuggestBox-input-token");
        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(suggestions, itemBox);
        box.getElement().setId("suggestion_box");
        item.add(box);
        list.add(item);

        // this needs to be on the itemBox rather than box, or backspace will
        // get executed twice
        itemBox.addKeyDownHandler(new KeyDownHandler() {
            // handle key events on the suggest box
            public void onKeyDown(KeyDownEvent event) {
                switch (event.getNativeKeyCode()) {
                    case KeyCodes.KEY_ENTER:
                        // only allow manual entries with @ signs (assumed email
                        // addresses)
                        if (itemBox.getValue().contains("@")) {
                            deselectItem(itemBox, list);
                        }
                        break;

                    // handle backspace
                    case KeyCodes.KEY_BACKSPACE:
                        if (itemBox.getValue().trim().isEmpty()) {
                            if (itemsHighlighted.isEmpty()) {
                                if (itemsSelected.size() > 0) {
                                    ListItem li = (ListItem) list.getWidget(list.getWidgetCount() - 2);
                                    Paragraph p = (Paragraph) li.getWidget(0);
                                    if (itemsSelected.contains(p.getText())) {
                                        // remove selected item
                                        itemsSelected.remove(p.getText());
                                    }
                                    list.remove(li);
                                }
                            }
                        }
                        // continue to delete
                    
                    // handle delete
                    case KeyCodes.KEY_DELETE:
                        if (itemBox.getValue().trim().isEmpty()) {
                            for (ListItem li : itemsHighlighted) {
                                list.remove(li);
                                Paragraph p = (Paragraph) li.getWidget(0);
                                itemsSelected.remove(p.getText());
                            }
                            itemsHighlighted.clear();
                        }
                        itemBox.setFocus(true);
                        break;
                }
            }
        });

        box.addSelectionHandler(new SelectionHandler<SuggestOracle.Suggestion>() {
            // called when an item is selected from list of suggestions
            public void onSelection(SelectionEvent<SuggestOracle.Suggestion> selectionEvent) {
                deselectItem(itemBox, list);
            }
        });

        panel.add(list);

        panel.getElement().setAttribute("onclick", "document.getElementById('suggestion_box').focus()");
        box.setFocus(true);
        /*
         * Div structure after a few elements have been added: <ul
         * class="multiValueSuggestBox-list"> <li
         * class="multiValueSuggestBox-token"> <p>What's New Scooby-Doo?</p>
         * <span class="multiValueSuggestBox-delete-token">x</span> </li> <li
         * class="multiValueSuggestBox-token"> <p>Fear Factor</p> <span
         * class="multiValueSuggestBox-delete-token">x</span> </li> <li
         * class="multiValueSuggestBox-input-token"> <input type="text" style=
         * "outline-color: -moz-use-text-color; outline-style: none; outline-width: medium;"
         * /> </li> </ul>
         */
    }

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

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

            displayItem.addClickHandler(new ClickHandler() {
                // called when a list item is clicked on
                public void onClick(ClickEvent clickEvent) {
                    if (itemsHighlighted.contains(displayItem)) { 
                        displayItem.removeStyleDependentName("selected");
                        itemsHighlighted.remove(displayItem);
                    }
                    else {
                        displayItem.addStyleDependentName("selected");
                        itemsHighlighted.add(displayItem);
                    }
                }
            });

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

            displayItem.add(p);
            displayItem.add(span);
            // hold the original value of the item selected

            // add selected item
            itemsSelected.add(itemBox.getValue());

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

    private void removeListItem(ListItem displayItem, BulletList list) {
        itemsSelected.remove(displayItem.getWidget(0).getElement().getInnerHTML());
        list.remove(displayItem);
    }
}

I have renamed the style names as follows:

ul.multiValueSuggestBox-list {
    overflow: hidden;
    height: auto !important;
    height: 1%;
    width: 400px;
    border: 1px solid #8496ba;
    cursor: text;
    font-size: 12px;
    font-family: Verdana;
    min-height: 1px;
    z-index: 999;
    margin: 0;
    padding: 0;
    background-color: #fff;
}

ul.multiValueSuggestBox-list {
    list-style-type: none;
}

ul.multiValueSuggestBox-list li input {
    border: 0;
    width: 100px;
    padding: 3px 8px;
    background-color: white;
    margin: 2px 0;
}

li.multiValueSuggestBox-token {
    overflow: hidden;
    height: auto !important;
    height: 1%;
    margin: 3px;
    padding: 1px 3px;
    background-color: #eff2f7;
    color: #000;
    cursor: default;
    border: 1px solid #ccd5e4;
    font-size: 11px;
    border-radius: 5px;
    -moz-border-radius: 5px;
    -webkit-border-radius: 5px;
    float: left;
}

li.multiValueSuggestBox-token p {
    display: inline;
    padding: 0;
    margin: 0;
}

li.multiValueSuggestBox-token span {
    color: #a6b3cf;
    margin-left: 5px;
    font-weight: bold;
    cursor: pointer;
}

li.multiValueSuggestBox-token-selected {
    background-color: #5670a6;
    border: 1px solid #3b5998;
    color: #fff;
}

li.multiValueSuggestBox-input-token {
    float: left;
}

Posted by Cengiz on July 31, 2012 at 10:33 AM MDT #

Very useful, thanks a lot! I wanted 2 of those in my webapp and I had problem with the focus so I added a

String uniqueId = DOM.createUniqueId();

in the constructor and changed the 2 lines referencing the DOM element as follow :

box.getElement().setId("suggestion_box"+uniqueId);
panel.getElement().setAttribute("onclick", "document.getElementById('suggestion_box"+uniqueId+"').focus()");

Best,
Thomas

Posted by Thomas on October 18, 2012 at 10:44 AM MDT #

Post a Comment:
  • HTML Syntax: Allowed