001: /*
002: * Copyright 2007 Google Inc.
003: *
004: * Licensed under the Apache License, Version 2.0 (the "License"); you may not
005: * use this file except in compliance with the License. You may obtain a copy of
006: * the License at
007: *
008: * http://www.apache.org/licenses/LICENSE-2.0
009: *
010: * Unless required by applicable law or agreed to in writing, software
011: * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
012: * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
013: * License for the specific language governing permissions and limitations under
014: * the License.
015: */
016: package com.google.gwt.user.client.ui;
017:
018: import com.google.gwt.user.client.Command;
019: import com.google.gwt.user.client.DOM;
020: import com.google.gwt.user.client.Window;
021: import com.google.gwt.user.client.ui.SuggestOracle.Callback;
022: import com.google.gwt.user.client.ui.SuggestOracle.Request;
023: import com.google.gwt.user.client.ui.SuggestOracle.Response;
024: import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
025:
026: import java.util.ArrayList;
027: import java.util.Collection;
028: import java.util.List;
029:
030: /**
031: * A {@link SuggestBox} is a text box or text area which displays a
032: * pre-configured set of selections that match the user's input.
033: *
034: * Each {@link SuggestBox} is associated with a single {@link SuggestOracle}.
035: * The {@link SuggestOracle} is used to provide a set of selections given a
036: * specific query string.
037: *
038: * <p>
039: * By default, the {@link SuggestBox} uses a {@link MultiWordSuggestOracle} as
040: * its oracle. Below we show how a {@link MultiWordSuggestOracle} can be
041: * configured:
042: * </p>
043: *
044: * <pre>
045: * MultiWordSuggestOracle oracle = new MultiWordSuggestOracle();
046: * oracle.add("Cat");
047: * oracle.add("Dog");
048: * oracle.add("Horse");
049: * oracle.add("Canary");
050: *
051: * SuggestBox box = new SuggestBox(oracle);
052: * </pre>
053: *
054: * Using the example above, if the user types "C" into the text widget, the
055: * oracle will configure the suggestions with the "Cat" and "Canary"
056: * suggestions. Specifically, whenever the user types a key into the text
057: * widget, the value is submitted to the <code>MultiWordSuggestOracle</code>.
058: *
059: * <p>
060: * Note that there is no method to retrieve the "currently selected suggestion"
061: * in a SuggestBox, because there are points in time where the currently
062: * selected suggestion is not defined. For example, if the user types in some
063: * text that does not match any of the SuggestBox's suggestions, then the
064: * SuggestBox will not have a currently selected suggestion. It is more useful
065: * to know when a suggestion has been chosen from the SuggestBox's list of
066: * suggestions. A SuggestBox fires
067: * {@link SuggestionEvent SuggestionEvents} whenever a suggestion is chosen, and
068: * handlers for these events can be added using the
069: * {@link #addEventHandler(SuggestionHandler)} method.
070: * </p>
071: *
072: * <p>
073: * <img class='gallery' src='SuggestBox.png'/>
074: * </p>
075: *
076: * <h3>CSS Style Rules</h3>
077: * <ul class='css'>
078: * <li>.gwt-SuggestBox { the suggest box itself }</li>
079: * <li>.gwt-SuggestBoxPopup { the suggestion popup }</li>
080: * <li>.gwt-SuggestBoxPopup .item { an unselected suggestion }</li>
081: * <li>.gwt-SuggestBoxPopup .item-selected { a selected suggestion }</li>
082: * </ul>
083: *
084: * @see SuggestOracle
085: * @see MultiWordSuggestOracle
086: * @see TextBoxBase
087: */
088: public final class SuggestBox extends Composite implements HasText,
089: HasFocus, SourcesClickEvents, SourcesFocusEvents,
090: SourcesChangeEvents, SourcesKeyboardEvents,
091: FiresSuggestionEvents {
092:
093: /**
094: * The SuggestionMenu class is used for the display and selection of
095: * suggestions in the SuggestBox widget. SuggestionMenu differs from
096: * MenuBar in that it always has a vertical orientation, and it
097: * has no submenus. It also allows for programmatic selection of items in
098: * the menu, and programmatically performing the action associated with the
099: * selected item. In the MenuBar class, items cannot be selected
100: * programatically - they can only be selected when the user places the
101: * mouse over a particlar item. Additional methods in SuggestionMenu provide
102: * information about the number of items in the menu, and the index of the
103: * currently selected item.
104: */
105: private static class SuggestionMenu extends MenuBar {
106:
107: public SuggestionMenu(boolean vertical) {
108: super (vertical);
109: // Make sure that CSS styles specified for the default Menu classes
110: // do not affect this menu
111: setStyleName("");
112: }
113:
114: public void doSelectedItemAction() {
115: // In order to perform the action of the item that is currently
116: // selected, the menu must be showing.
117: MenuItem selectedItem = getSelectedItem();
118: if (selectedItem != null) {
119: doItemAction(selectedItem, true);
120: }
121: }
122:
123: public int getNumItems() {
124: return getItems().size();
125: }
126:
127: /**
128: * Returns the index of the menu item that is currently selected.
129: */
130: public int getSelectedItemIndex() {
131: // The index of the currently selected item can only be
132: // obtained if the menu is showing.
133: MenuItem selectedItem = getSelectedItem();
134: if (selectedItem != null) {
135: return getItems().indexOf(selectedItem);
136: }
137: return -1;
138: }
139:
140: /**
141: * Selects the item at the specified index in the menu. Selecting the item
142: * does not perform the item's associated action; it only changes the style
143: * of the item and updates the value of SuggestionMenu.selectedItem.
144: */
145: public void selectItem(int index) {
146: List<MenuItem> items = getItems();
147: if (index > -1 && index < items.size()) {
148: itemOver(items.get(index));
149: }
150: }
151: }
152:
153: /**
154: * Class for menu items in a SuggestionMenu. A SuggestionMenuItem differs
155: * from a MenuItem in that each item is backed by a Suggestion object.
156: * The text of each menu item is derived from the display string of a
157: * Suggestion object, and each item stores a reference to its Suggestion
158: * object.
159: */
160: private static class SuggestionMenuItem extends MenuItem {
161:
162: private static final String STYLENAME_DEFAULT = "item";
163:
164: private Suggestion suggestion;
165:
166: public SuggestionMenuItem(Suggestion suggestion, boolean asHTML) {
167: super (suggestion.getDisplayString(), asHTML);
168: // Each suggestion should be placed in a single row in the suggestion
169: // menu. If the window is resized and the suggestion cannot fit on a
170: // single row, it should be clipped (instead of wrapping around and
171: // taking up a second row).
172: DOM.setStyleAttribute(getElement(), "whiteSpace", "nowrap");
173: setStyleName(STYLENAME_DEFAULT);
174: setSuggestion(suggestion);
175: }
176:
177: public Suggestion getSuggestion() {
178: return suggestion;
179: }
180:
181: public void setSuggestion(Suggestion suggestion) {
182: this .suggestion = suggestion;
183: }
184: }
185:
186: /**
187: * A PopupPanel with a SuggestionMenu as its widget. The SuggestionMenu is
188: * placed in a PopupPanel so that it can be displayed at various positions
189: * around the SuggestBox's text field. Moreover, the SuggestionMenu
190: * needs to appear on top of any other widgets on the page, and the PopupPanel
191: * provides this behavior.
192: *
193: * A non-static member class is used because the popup uses the SuggestBox's
194: * SuggestionMenu as its widget, and the position of the SuggestBox's TextBox
195: * is needed in order to correctly position the popup.
196: */
197: private class SuggestionPopup extends PopupPanel {
198:
199: private static final String STYLENAME_DEFAULT = "gwt-SuggestBoxPopup";
200:
201: public SuggestionPopup() {
202: super (true);
203: setWidget(suggestionMenu);
204: setStyleName(STYLENAME_DEFAULT);
205: }
206:
207: /**
208: * The default position of the SuggestPopup is directly below the
209: * SuggestBox's text box, with its left edge aligned with the left edge of
210: * the text box. Depending on the width and height of the popup and the
211: * distance from the text box to the bottom and right edges of the window,
212: * the popup may be displayed directly above the text box, and/or its right
213: * edge may be aligned with the right edge of the text box.
214: */
215: public void showAlignedPopup() {
216:
217: // Set the position of the popup right before it is shown.
218: setPopupPositionAndShow(new PositionCallback() {
219: public void setPosition(int offsetWidth,
220: int offsetHeight) {
221: // Calculate left position for the popup.
222:
223: int left = box.getAbsoluteLeft();
224: int offsetWidthDiff = offsetWidth
225: - box.getOffsetWidth();
226:
227: // If the suggestion popup is not as wide as the text box, always align
228: // to the left edge of the text box. Otherwise, figure out whether to
229: // left-align or right-align the popup.
230: if (offsetWidthDiff > 0) {
231: // Make sure scrolling is taken into account, since box.getAbsoluteLeft()
232: // takes scrolling into account.
233: int windowRight = Window.getClientWidth()
234: + Window.getScrollLeft();
235: int windowLeft = Window.getScrollLeft();
236:
237: // Distance from the left edge of the text box to the right edge of the
238: // window
239: int distanceToWindowRight = windowRight - left;
240:
241: // Distance from the left edge of the text box to the left edge of the
242: // window
243: int distanceFromWindowLeft = left - windowLeft;
244:
245: // If there is not enough space for the popup's width overflow to the
246: // right of the text box and there IS enough space for the popup's
247: // width overflow to the left of the text box, then right-align
248: // the popup. However, if there is not enough space on either side,
249: // then stick with left-alignment.
250: if (distanceToWindowRight < offsetWidth
251: && distanceFromWindowLeft >= (offsetWidth - box
252: .getOffsetWidth())) {
253: // Align with the right edge of the text box.
254: left -= offsetWidthDiff;
255: }
256: }
257:
258: // Calculate top position for the popup
259:
260: int top = box.getAbsoluteTop();
261:
262: // Make sure scrolling is taken into account, since box.getAbsoluteTop()
263: // takes scrolling into account.
264: int windowTop = Window.getScrollTop();
265: int windowBottom = Window.getScrollTop()
266: + Window.getClientHeight();
267:
268: // Distance from the top edge of the window to the top edge of the text box
269: int distanceFromWindowTop = top - windowTop;
270:
271: // Distance from the bottom edge of the window to the bottom edge of the
272: // text box
273: int distanceToWindowBottom = windowBottom
274: - (top + box.getOffsetHeight());
275:
276: // If there is not enough space for the popup's height below the text box
277: // and there IS enough space for the popup's height above the text box,
278: // then then position the popup above the text box. However, if there is
279: // not enough space on either side, then stick with displaying the popup
280: // below the text box.
281: if (distanceToWindowBottom < offsetHeight
282: && distanceFromWindowTop >= offsetHeight) {
283: top -= offsetHeight;
284: } else {
285: // Position above the text box
286: top += box.getOffsetHeight();
287: }
288:
289: setPopupPosition(left, top);
290: }
291: });
292: }
293: }
294:
295: private static final String STYLENAME_DEFAULT = "gwt-SuggestBox";
296:
297: private int limit = 20;
298: private SuggestOracle oracle;
299: private String currentText;
300: private final SuggestionMenu suggestionMenu;
301: private final SuggestionPopup suggestionPopup;
302: private final TextBoxBase box;
303: private ArrayList<SuggestionHandler> suggestionHandlers = null;
304: private DelegatingClickListenerCollection clickListeners;
305: private DelegatingChangeListenerCollection changeListeners;
306: private DelegatingFocusListenerCollection focusListeners;
307: private DelegatingKeyboardListenerCollection keyboardListeners;
308:
309: private final Callback callBack = new Callback() {
310: public void onSuggestionsReady(Request request,
311: Response response) {
312: showSuggestions(response.getSuggestions());
313: }
314: };
315:
316: /**
317: * Constructor for {@link SuggestBox}. Creates a
318: * {@link MultiWordSuggestOracle} and {@link TextBox} to use with this
319: * {@link SuggestBox}.
320: */
321: public SuggestBox() {
322: this (new MultiWordSuggestOracle());
323: }
324:
325: /**
326: * Constructor for {@link SuggestBox}. Creates a {@link TextBox} to use with
327: * this {@link SuggestBox}.
328: *
329: * @param oracle the oracle for this <code>SuggestBox</code>
330: */
331: public SuggestBox(SuggestOracle oracle) {
332: this (oracle, new TextBox());
333: }
334:
335: /**
336: * Constructor for {@link SuggestBox}. The text box will be removed from it's
337: * current location and wrapped by the {@link SuggestBox}.
338: *
339: * @param oracle supplies suggestions based upon the current contents of the
340: * text widget
341: * @param box the text widget
342: */
343: public SuggestBox(SuggestOracle oracle, TextBoxBase box) {
344: this .box = box;
345: initWidget(box);
346:
347: // suggestionMenu must be created before suggestionPopup, because
348: // suggestionMenu is suggestionPopup's widget
349: suggestionMenu = new SuggestionMenu(true);
350: suggestionPopup = new SuggestionPopup();
351:
352: addKeyboardSupport();
353: setOracle(oracle);
354: setStyleName(STYLENAME_DEFAULT);
355: }
356:
357: /**
358: * Adds a listener to recieve change events on the SuggestBox's text box.
359: * The source Widget for these events will be the SuggestBox.
360: *
361: * @param listener the listener interface to add
362: */
363: public final void addChangeListener(ChangeListener listener) {
364: if (changeListeners == null) {
365: changeListeners = new DelegatingChangeListenerCollection(
366: this , box);
367: }
368: changeListeners.add(listener);
369: }
370:
371: /**
372: * Adds a listener to recieve click events on the SuggestBox's text box.
373: * The source Widget for these events will be the SuggestBox.
374: *
375: * @param listener the listener interface to add
376: */
377: public final void addClickListener(ClickListener listener) {
378: if (clickListeners == null) {
379: clickListeners = new DelegatingClickListenerCollection(
380: this , box);
381: }
382: clickListeners.add(listener);
383: }
384:
385: public final void addEventHandler(SuggestionHandler handler) {
386: if (suggestionHandlers == null) {
387: suggestionHandlers = new ArrayList<SuggestionHandler>();
388: }
389: suggestionHandlers.add(handler);
390: }
391:
392: /**
393: * Adds a listener to recieve focus events on the SuggestBox's text box.
394: * The source Widget for these events will be the SuggestBox.
395: *
396: * @param listener the listener interface to add
397: */
398: public final void addFocusListener(FocusListener listener) {
399: if (focusListeners == null) {
400: focusListeners = new DelegatingFocusListenerCollection(
401: this , box);
402: }
403: focusListeners.add(listener);
404: }
405:
406: /**
407: * Adds a listener to recieve keyboard events on the SuggestBox's text box.
408: * The source Widget for these events will be the SuggestBox.
409: *
410: * @param listener the listener interface to add
411: */
412: public final void addKeyboardListener(KeyboardListener listener) {
413: if (keyboardListeners == null) {
414: keyboardListeners = new DelegatingKeyboardListenerCollection(
415: this , box);
416: }
417: keyboardListeners.add(listener);
418: }
419:
420: /**
421: * Gets the limit for the number of suggestions that should be displayed for
422: * this box. It is up to the current {@link SuggestOracle} to enforce this
423: * limit.
424: *
425: * @return the limit for the number of suggestions
426: */
427: public final int getLimit() {
428: return limit;
429: }
430:
431: /**
432: * Gets the suggest box's {@link com.google.gwt.user.client.ui.SuggestOracle}.
433: *
434: * @return the {@link SuggestOracle}
435: */
436: public final SuggestOracle getSuggestOracle() {
437: return oracle;
438: }
439:
440: public final int getTabIndex() {
441: return box.getTabIndex();
442: }
443:
444: public final String getText() {
445: return box.getText();
446: }
447:
448: public final void removeChangeListener(ChangeListener listener) {
449: if (changeListeners != null) {
450: changeListeners.remove(listener);
451: }
452: }
453:
454: public final void removeClickListener(ClickListener listener) {
455: if (clickListeners != null) {
456: clickListeners.remove(listener);
457: }
458: }
459:
460: public final void removeEventHandler(SuggestionHandler handler) {
461: if (suggestionHandlers == null) {
462: return;
463: }
464: suggestionHandlers.remove(handler);
465: }
466:
467: public final void removeFocusListener(FocusListener listener) {
468: if (focusListeners != null) {
469: focusListeners.remove(listener);
470: }
471: }
472:
473: public final void removeKeyboardListener(KeyboardListener listener) {
474: if (keyboardListeners != null) {
475: keyboardListeners.remove(listener);
476: }
477: }
478:
479: public final void setAccessKey(char key) {
480: box.setAccessKey(key);
481: }
482:
483: public final void setFocus(boolean focused) {
484: box.setFocus(focused);
485: }
486:
487: /**
488: * Sets the limit to the number of suggestions the oracle should provide. It
489: * is up to the oracle to enforce this limit.
490: *
491: * @param limit the limit to the number of suggestions provided
492: */
493: public final void setLimit(int limit) {
494: this .limit = limit;
495: }
496:
497: /**
498: * Sets the style name of the suggestion popup.
499: *
500: * @param style the new primary style name
501: * @see UIObject#setStyleName(String)
502: */
503: public final void setPopupStyleName(String style) {
504: suggestionPopup.setStyleName(style);
505: }
506:
507: public final void setTabIndex(int index) {
508: box.setTabIndex(index);
509: }
510:
511: public final void setText(String text) {
512: box.setText(text);
513: }
514:
515: /**
516: * Show the given collection of suggestions.
517: *
518: * @param suggestions suggestions to show
519: */
520: private void showSuggestions(
521: Collection<? extends Suggestion> suggestions) {
522: if (suggestions.size() > 0) {
523:
524: /* Hide the popup before we manipulate the menu within it. If we do not
525: do this, some browsers will redraw the popup as items are removed
526: and added to the menu.
527:
528: As an optimization, setVisible(false) is used in place of the hide()
529: method. hide() removes the popup from the DOM, whereas setVisible(false)
530: does not. Since the popup is going to be shown again as soon as the menu
531: is rebuilt, it makes more sense to leave the popup attached to the DOM.
532:
533: Notice that setVisible(true) is never called. This is because the call
534: to showAlignedPopup() will cause show() to be called, which in turn
535: calls setVisible(true). */
536: suggestionPopup.setVisible(false);
537:
538: suggestionMenu.clearItems();
539:
540: for (Suggestion curSuggestion : suggestions) {
541: final SuggestionMenuItem menuItem = new SuggestionMenuItem(
542: curSuggestion, oracle.isDisplayStringHTML());
543: menuItem.setCommand(new Command() {
544: public void execute() {
545: SuggestBox.this .setNewSelection(menuItem);
546: }
547: });
548:
549: suggestionMenu.addItem(menuItem);
550: }
551:
552: // Select the first item in the suggestion menu.
553: suggestionMenu.selectItem(0);
554:
555: suggestionPopup.showAlignedPopup();
556: } else {
557: suggestionPopup.hide();
558: }
559: }
560:
561: private void addKeyboardSupport() {
562: box.addKeyboardListener(new KeyboardListenerAdapter() {
563:
564: @Override
565: public void onKeyDown(Widget sender, char keyCode,
566: int modifiers) {
567: // Make sure that the menu is actually showing. These keystrokes
568: // are only relevant when choosing a suggestion.
569: if (suggestionPopup.isAttached()) {
570: switch (keyCode) {
571: case KeyboardListener.KEY_DOWN:
572: suggestionMenu.selectItem(suggestionMenu
573: .getSelectedItemIndex() + 1);
574: break;
575: case KeyboardListener.KEY_UP:
576: suggestionMenu.selectItem(suggestionMenu
577: .getSelectedItemIndex() - 1);
578: break;
579: case KeyboardListener.KEY_ENTER:
580: case KeyboardListener.KEY_TAB:
581: suggestionMenu.doSelectedItemAction();
582: break;
583: }
584: }
585: }
586:
587: @Override
588: public void onKeyUp(Widget sender, char keyCode,
589: int modifiers) {
590: // After every user key input, refresh the popup's suggestions.
591: refreshSuggestions();
592: }
593:
594: private void refreshSuggestions() {
595: // Get the raw text.
596: String text = box.getText();
597: if (text.equals(currentText)) {
598: return;
599: } else {
600: currentText = text;
601: }
602:
603: if (text.length() == 0) {
604: // Optimization to avoid calling showSuggestions with an empty
605: // string
606: suggestionPopup.hide();
607: suggestionMenu.clearItems();
608: } else {
609: showSuggestions(text);
610: }
611: }
612: });
613: }
614:
615: private void fireSuggestionEvent(Suggestion selectedSuggestion) {
616: if (suggestionHandlers != null) {
617: SuggestionEvent event = new SuggestionEvent(this ,
618: selectedSuggestion);
619: for (SuggestionHandler handler : suggestionHandlers) {
620: handler.onSuggestionSelected(event);
621: }
622: }
623: }
624:
625: private void setNewSelection(SuggestionMenuItem menuItem) {
626: Suggestion curSuggestion = menuItem.getSuggestion();
627: currentText = curSuggestion.getReplacementString();
628: box.setText(currentText);
629: suggestionPopup.hide();
630: fireSuggestionEvent(curSuggestion);
631: }
632:
633: /**
634: * Sets the suggestion oracle used to create suggestions.
635: *
636: * @param oracle the oracle
637: */
638: private void setOracle(SuggestOracle oracle) {
639: this .oracle = oracle;
640: }
641:
642: private void showSuggestions(String query) {
643: oracle.requestSuggestions(new Request(query, limit), callBack);
644: }
645: }
|