diff options
author | Henri Sara <hesara@vaadin.com> | 2016-08-25 12:57:37 +0300 |
---|---|---|
committer | Vaadin Code Review <review@vaadin.com> | 2016-09-12 10:26:25 +0000 |
commit | 323d43711c572d7d676e044f1720fcc419a3c9d6 (patch) | |
tree | 467e42072ef24be00163ea5d2ead4f346fa95ccf /client | |
parent | 376a4d5b897327e957483bd43e7075f180a02e8f (diff) | |
download | vaadin-framework-323d43711c572d7d676e044f1720fcc419a3c9d6.tar.gz vaadin-framework-323d43711c572d7d676e044f1720fcc419a3c9d6.zip |
Update ComboBox for new DataSource and communication mechanism
This simplifies the client side state machine.
This change does not modify the CSS class name v-filterselect.
Change-Id: I2f4a6e5252045cb7698d582be90693e00961b342
Diffstat (limited to 'client')
3 files changed, 3180 insertions, 0 deletions
diff --git a/client/src/main/java/com/vaadin/client/ui/JsniMousewheelHandler.java b/client/src/main/java/com/vaadin/client/ui/JsniMousewheelHandler.java new file mode 100644 index 0000000000..7911b393e8 --- /dev/null +++ b/client/src/main/java/com/vaadin/client/ui/JsniMousewheelHandler.java @@ -0,0 +1,75 @@ +/* + * Copyright 2000-2016 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.client.ui; + +import com.google.gwt.core.client.JavaScriptObject; +import com.google.gwt.dom.client.Element; +import com.google.gwt.user.client.Event; +import com.google.gwt.user.client.ui.Widget; +import com.vaadin.client.widgets.Escalator; + +/** + * A mousewheel handling class to get around the limits of + * {@link Event#ONMOUSEWHEEL}. + * + * For internal use only. May be removed or replaced in the future. + * + * @see Escalator.JsniWorkaround + */ +// FIXME remove the copy in v7 package +abstract class JsniMousewheelHandler { + + /** + * A JavaScript function that handles the mousewheel DOM event, and passes + * it on to Java code. + * + * @see #createMousewheelListenerFunction(Widget) + */ + protected final JavaScriptObject mousewheelListenerFunction; + + protected JsniMousewheelHandler(final Widget widget) { + mousewheelListenerFunction = createMousewheelListenerFunction(widget); + } + + /** + * A method that constructs the JavaScript function that will be stored into + * {@link #mousewheelListenerFunction}. + * + * @param widget + * a reference to the current instance of {@link Widget} + */ + protected abstract JavaScriptObject createMousewheelListenerFunction( + Widget widget); + + public native void attachMousewheelListener(Element element) + /*-{ + if (element.addEventListener) { + // FireFox likes "wheel", while others use "mousewheel" + var eventName = 'onmousewheel' in element ? 'mousewheel' : 'wheel'; + element.addEventListener(eventName, this.@com.vaadin.client.ui.JsniMousewheelHandler::mousewheelListenerFunction); + } + }-*/; + + public native void detachMousewheelListener(Element element) + /*-{ + if (element.addEventListener) { + // FireFox likes "wheel", while others use "mousewheel" + var eventName = element.onwheel===undefined?"mousewheel":"wheel"; + element.removeEventListener(eventName, this.@com.vaadin.client.ui.JsniMousewheelHandler::mousewheelListenerFunction); + } + }-*/; + +} diff --git a/client/src/main/java/com/vaadin/client/ui/VComboBox.java b/client/src/main/java/com/vaadin/client/ui/VComboBox.java new file mode 100644 index 0000000000..623393f549 --- /dev/null +++ b/client/src/main/java/com/vaadin/client/ui/VComboBox.java @@ -0,0 +1,2766 @@ +/* + * Copyright 2000-2016 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package com.vaadin.client.ui; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +import com.google.gwt.aria.client.Roles; +import com.google.gwt.core.client.JavaScriptObject; +import com.google.gwt.core.client.Scheduler.ScheduledCommand; +import com.google.gwt.dom.client.Document; +import com.google.gwt.dom.client.Element; +import com.google.gwt.dom.client.NativeEvent; +import com.google.gwt.dom.client.Style; +import com.google.gwt.dom.client.Style.Display; +import com.google.gwt.dom.client.Style.Unit; +import com.google.gwt.dom.client.Style.Visibility; +import com.google.gwt.event.dom.client.BlurEvent; +import com.google.gwt.event.dom.client.BlurHandler; +import com.google.gwt.event.dom.client.ClickEvent; +import com.google.gwt.event.dom.client.ClickHandler; +import com.google.gwt.event.dom.client.FocusEvent; +import com.google.gwt.event.dom.client.FocusHandler; +import com.google.gwt.event.dom.client.KeyCodes; +import com.google.gwt.event.dom.client.KeyDownEvent; +import com.google.gwt.event.dom.client.KeyDownHandler; +import com.google.gwt.event.dom.client.KeyUpEvent; +import com.google.gwt.event.dom.client.KeyUpHandler; +import com.google.gwt.event.dom.client.LoadEvent; +import com.google.gwt.event.dom.client.LoadHandler; +import com.google.gwt.event.dom.client.MouseDownEvent; +import com.google.gwt.event.dom.client.MouseDownHandler; +import com.google.gwt.event.logical.shared.CloseEvent; +import com.google.gwt.event.logical.shared.CloseHandler; +import com.google.gwt.i18n.client.HasDirection.Direction; +import com.google.gwt.user.client.Command; +import com.google.gwt.user.client.DOM; +import com.google.gwt.user.client.Event; +import com.google.gwt.user.client.Timer; +import com.google.gwt.user.client.Window; +import com.google.gwt.user.client.ui.Composite; +import com.google.gwt.user.client.ui.FlowPanel; +import com.google.gwt.user.client.ui.HTML; +import com.google.gwt.user.client.ui.PopupPanel; +import com.google.gwt.user.client.ui.PopupPanel.PositionCallback; +import com.google.gwt.user.client.ui.SuggestOracle.Suggestion; +import com.google.gwt.user.client.ui.TextBox; +import com.google.gwt.user.client.ui.Widget; +import com.vaadin.client.ApplicationConnection; +import com.vaadin.client.BrowserInfo; +import com.vaadin.client.ComputedStyle; +import com.vaadin.client.DeferredWorker; +import com.vaadin.client.Focusable; +import com.vaadin.client.VConsole; +import com.vaadin.client.WidgetUtil; +import com.vaadin.client.ui.aria.AriaHelper; +import com.vaadin.client.ui.aria.HandlesAriaCaption; +import com.vaadin.client.ui.aria.HandlesAriaInvalid; +import com.vaadin.client.ui.aria.HandlesAriaRequired; +import com.vaadin.client.ui.combobox.ComboBoxConnector; +import com.vaadin.client.ui.menubar.MenuBar; +import com.vaadin.client.ui.menubar.MenuItem; +import com.vaadin.shared.AbstractComponentState; +import com.vaadin.shared.ui.ComponentStateUtil; +import com.vaadin.shared.util.SharedUtil; + +/** + * Client side implementation of the ComboBox component. + * + * TODO needs major refactoring (to be extensible etc) + */ +@SuppressWarnings("deprecation") +public class VComboBox extends Composite implements Field, KeyDownHandler, + KeyUpHandler, ClickHandler, FocusHandler, BlurHandler, Focusable, + SubPartAware, HandlesAriaCaption, HandlesAriaInvalid, + HandlesAriaRequired, DeferredWorker, MouseDownHandler { + + /** + * Represents a suggestion in the suggestion popup box. + */ + public class ComboBoxSuggestion implements Suggestion, Command { + + private final String key; + private final String caption; + private String untranslatedIconUri; + private String style; + + /** + * Constructor for a single suggestion. + * + * @param key + * item key, empty string for a special null item not in + * container + * @param caption + * item caption + * @param style + * item style name, can be empty string + * @param untranslatedIconUri + * icon URI or null + */ + public ComboBoxSuggestion(String key, String caption, String style, + String untranslatedIconUri) { + this.key = key; + this.caption = caption; + this.style = style; + this.untranslatedIconUri = untranslatedIconUri; + } + + /** + * Gets the visible row in the popup as a HTML string. The string + * contains an image tag with the rows icon (if an icon has been + * specified) and the caption of the item + */ + + @Override + public String getDisplayString() { + final StringBuffer sb = new StringBuffer(); + ApplicationConnection client = connector.getConnection(); + final Icon icon = client + .getIcon(client.translateVaadinUri(untranslatedIconUri)); + if (icon != null) { + sb.append(icon.getElement().getString()); + } + String content; + if ("".equals(caption)) { + // Ensure that empty options use the same height as other + // options and are not collapsed (#7506) + content = " "; + } else { + content = WidgetUtil.escapeHTML(caption); + } + sb.append("<span>" + content + "</span>"); + return sb.toString(); + } + + /** + * Get a string that represents this item. This is used in the text box. + */ + + @Override + public String getReplacementString() { + return caption; + } + + /** + * Get the option key which represents the item on the server side. + * + * @return The key of the item + */ + public String getOptionKey() { + return key; + } + + /** + * Get the URI of the icon. Used when constructing the displayed option. + * + * @return real (translated) icon URI or null if none + */ + public String getIconUri() { + ApplicationConnection client = connector.getConnection(); + return client.translateVaadinUri(untranslatedIconUri); + } + + /** + * Gets the style set for this suggestion item. Styles are typically set + * by a server-side {@link com.vaadin.ui.ComboBox.ItemStyleProvider}. + * The returned style is prefixed by <code>v-filterselect-item-</code>. + * + * @since 7.5.6 + * @return the style name to use, or <code>null</code> to not apply any + * custom style. + */ + public String getStyle() { + return style; + } + + /** + * Executes a selection of this item. + */ + + @Override + public void execute() { + onSuggestionSelected(this); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof ComboBoxSuggestion)) { + return false; + } + ComboBoxSuggestion other = (ComboBoxSuggestion) obj; + if ((key == null && other.key != null) + || (key != null && !key.equals(other.key))) { + return false; + } + if ((caption == null && other.caption != null) + || (caption != null && !caption.equals(other.caption))) { + return false; + } + if (!SharedUtil.equals(untranslatedIconUri, + other.untranslatedIconUri)) { + return false; + } + if (!SharedUtil.equals(style, other.style)) { + return false; + } + return true; + } + } + + /** An inner class that handles all logic related to mouse wheel. */ + private class MouseWheeler extends JsniMousewheelHandler { + + public MouseWheeler() { + super(VComboBox.this); + } + + @Override + protected native JavaScriptObject createMousewheelListenerFunction( + Widget widget) + /*-{ + return $entry(function(e) { + var deltaX = e.deltaX ? e.deltaX : -0.5*e.wheelDeltaX; + var deltaY = e.deltaY ? e.deltaY : -0.5*e.wheelDeltaY; + + // IE8 has only delta y + if (isNaN(deltaY)) { + deltaY = -0.5*e.wheelDelta; + } + + @com.vaadin.client.ui.VComboBox.JsniUtil::moveScrollFromEvent(*)(widget, deltaX, deltaY, e, e.deltaMode); + }); + }-*/; + + } + + /** + * A utility class that contains utility methods that are usually called + * from JSNI. + * <p> + * The methods are moved in this class to minimize the amount of JSNI code + * as much as feasible. + */ + static class JsniUtil { + private static final int DOM_DELTA_PIXEL = 0; + private static final int DOM_DELTA_LINE = 1; + private static final int DOM_DELTA_PAGE = 2; + + // Rough estimation of item height + private static final int SCROLL_UNIT_PX = 25; + + private static double deltaSum = 0; + + public static void moveScrollFromEvent(final Widget widget, + final double deltaX, final double deltaY, + final NativeEvent event, final int deltaMode) { + + if (!Double.isNaN(deltaY)) { + VComboBox filterSelect = (VComboBox) widget; + + switch (deltaMode) { + case DOM_DELTA_LINE: + if (deltaY >= 0) { + filterSelect.suggestionPopup.selectNextItem(); + } else { + filterSelect.suggestionPopup.selectPrevItem(); + } + break; + case DOM_DELTA_PAGE: + if (deltaY >= 0) { + filterSelect.selectNextPage(); + } else { + filterSelect.selectPrevPage(); + } + break; + case DOM_DELTA_PIXEL: + default: + // Accumulate dampened deltas + deltaSum += Math.pow(Math.abs(deltaY), 0.7) + * Math.signum(deltaY); + + // "Scroll" if change exceeds item height + while (Math.abs(deltaSum) >= SCROLL_UNIT_PX) { + if (!filterSelect.dataReceivedHandler + .isWaitingForFilteringResponse()) { + // Move selection if page flip is not in progress + if (deltaSum < 0) { + filterSelect.suggestionPopup.selectPrevItem(); + } else { + filterSelect.suggestionPopup.selectNextItem(); + } + } + deltaSum -= SCROLL_UNIT_PX * Math.signum(deltaSum); + } + break; + } + } + } + } + + /** + * Represents the popup box with the selection options. Wraps a suggestion + * menu. + */ + public class SuggestionPopup extends VOverlay + implements PositionCallback, CloseHandler<PopupPanel> { + + private static final int Z_INDEX = 30000; + + /** For internal use only. May be removed or replaced in the future. */ + public final SuggestionMenu menu; + + private final Element up = DOM.createDiv(); + private final Element down = DOM.createDiv(); + private final Element status = DOM.createDiv(); + + private boolean isPagingEnabled = true; + + private long lastAutoClosed; + + private int popupOuterPadding = -1; + + private int topPosition; + + private final MouseWheeler mouseWheeler = new MouseWheeler(); + + /** + * Default constructor + */ + SuggestionPopup() { + super(true, false); + debug("VComboBox.SP: constructor()"); + setOwner(VComboBox.this); + menu = new SuggestionMenu(); + setWidget(menu); + + getElement().getStyle().setZIndex(Z_INDEX); + + final Element root = getContainerElement(); + + up.setInnerHTML("<span>Prev</span>"); + DOM.sinkEvents(up, Event.ONCLICK); + + down.setInnerHTML("<span>Next</span>"); + DOM.sinkEvents(down, Event.ONCLICK); + + root.insertFirst(up); + root.appendChild(down); + root.appendChild(status); + + DOM.sinkEvents(root, Event.ONMOUSEDOWN | Event.ONMOUSEWHEEL); + addCloseHandler(this); + + Roles.getListRole().set(getElement()); + + setPreviewingAllNativeEvents(true); + } + + @Override + protected void onLoad() { + super.onLoad(); + + // Register mousewheel listener on paged select + if (pageLength > 0) { + mouseWheeler.attachMousewheelListener(getElement()); + } + } + + @Override + protected void onUnload() { + mouseWheeler.detachMousewheelListener(getElement()); + super.onUnload(); + } + + /** + * Shows the popup where the user can see the filtered options that have + * been set with a call to + * {@link SuggestionMenu#setSuggestions(Collection)}. + * + * @param currentPage + * The current page number + * @param totalSuggestions + * The total amount of suggestions + */ + public void showSuggestions(final int currentPage, + final int totalSuggestions) { + + debug("VComboBox.SP: showSuggestions(" + currentPage + ", " + + totalSuggestions + ")"); + + final SuggestionPopup popup = this; + // Add TT anchor point + getElement().setId("VAADIN_COMBOBOX_OPTIONLIST"); + + final int x = VComboBox.this.getAbsoluteLeft(); + + topPosition = tb.getAbsoluteTop(); + topPosition += tb.getOffsetHeight(); + + setPopupPosition(x, topPosition); + + int nullOffset = (nullSelectionAllowed && "".equals(lastFilter) ? 1 + : 0); + boolean firstPage = (currentPage == 0); + final int first = currentPage * pageLength + 1 + - (firstPage ? 0 : nullOffset); + final int last = first + currentSuggestions.size() - 1 + - (firstPage && "".equals(lastFilter) ? nullOffset : 0); + final int matches = totalSuggestions - nullOffset; + if (last > 0) { + // nullsel not counted, as requested by user + status.setInnerText((matches == 0 ? 0 : first) + "-" + last + + "/" + matches); + } else { + status.setInnerText(""); + } + // We don't need to show arrows or statusbar if there is + // only one page + if (totalSuggestions <= pageLength || pageLength == 0) { + setPagingEnabled(false); + } else { + setPagingEnabled(true); + } + setPrevButtonActive(first > 1); + setNextButtonActive(last < matches); + + // clear previously fixed width + menu.setWidth(""); + menu.getElement().getFirstChildElement().getStyle().clearWidth(); + + setPopupPositionAndShow(popup); + } + + /** + * Should the next page button be visible to the user? + * + * @param active + */ + private void setNextButtonActive(boolean active) { + if (enableDebug) { + debug("VComboBox.SP: setNextButtonActive(" + active + ")"); + } + if (active) { + DOM.sinkEvents(down, Event.ONCLICK); + down.setClassName( + VComboBox.this.getStylePrimaryName() + "-nextpage"); + } else { + DOM.sinkEvents(down, 0); + down.setClassName( + VComboBox.this.getStylePrimaryName() + "-nextpage-off"); + } + } + + /** + * Should the previous page button be visible to the user + * + * @param active + */ + private void setPrevButtonActive(boolean active) { + if (enableDebug) { + debug("VComboBox.SP: setPrevButtonActive(" + active + ")"); + } + + if (active) { + DOM.sinkEvents(up, Event.ONCLICK); + up.setClassName( + VComboBox.this.getStylePrimaryName() + "-prevpage"); + } else { + DOM.sinkEvents(up, 0); + up.setClassName( + VComboBox.this.getStylePrimaryName() + "-prevpage-off"); + } + + } + + /** + * Selects the next item in the filtered selections. + */ + public void selectNextItem() { + debug("VComboBox.SP: selectNextItem()"); + + final int index = menu.getSelectedIndex() + 1; + if (menu.getItems().size() > index) { + selectItem(menu.getItems().get(index)); + + } else { + selectNextPage(); + } + } + + /** + * Selects the previous item in the filtered selections. + */ + public void selectPrevItem() { + debug("VComboBox.SP: selectPrevItem()"); + + final int index = menu.getSelectedIndex() - 1; + if (index > -1) { + selectItem(menu.getItems().get(index)); + + } else if (index == -1) { + selectPrevPage(); + + } else { + if (!menu.getItems().isEmpty()) { + selectLastItem(); + } + } + } + + /** + * Select the first item of the suggestions list popup. + * + * @since 7.2.6 + */ + public void selectFirstItem() { + debug("VComboBox.SP: selectFirstItem()"); + selectItem(menu.getFirstItem()); + } + + /** + * Select the last item of the suggestions list popup. + * + * @since 7.2.6 + */ + public void selectLastItem() { + debug("VComboBox.SP: selectLastItem()"); + selectItem(menu.getLastItem()); + } + + /* + * Sets the selected item in the popup menu. + */ + private void selectItem(final MenuItem newSelectedItem) { + menu.selectItem(newSelectedItem); + + // Set the icon. + ComboBoxSuggestion suggestion = (ComboBoxSuggestion) newSelectedItem + .getCommand(); + setSelectedItemIcon(suggestion.getIconUri()); + + // Set the text. + setText(suggestion.getReplacementString()); + + } + + /* + * Using a timer to scroll up or down the pages so when we receive lots + * of consecutive mouse wheel events the pages does not flicker. + */ + private LazyPageScroller lazyPageScroller = new LazyPageScroller(); + + private class LazyPageScroller extends Timer { + private int pagesToScroll = 0; + + @Override + public void run() { + debug("VComboBox.SP.LPS: run()"); + if (pagesToScroll != 0) { + if (!dataReceivedHandler.isWaitingForFilteringResponse()) { + /* + * Avoid scrolling while we are waiting for a response + * because otherwise the waiting flag will be reset in + * the first response and the second response will be + * ignored, causing an empty popup... + * + * As long as the scrolling delay is suitable + * double/triple clicks will work by scrolling two or + * three pages at a time and this should not be a + * problem. + */ + // this makes sure that we don't close the popup + dataReceivedHandler.setNavigationCallback(() -> { + }); + filterOptions(currentPage + pagesToScroll, lastFilter); + } + pagesToScroll = 0; + } + } + + public void scrollUp() { + debug("VComboBox.SP.LPS: scrollUp()"); + if (pageLength > 0 && currentPage + pagesToScroll > 0) { + pagesToScroll--; + cancel(); + schedule(200); + } + } + + public void scrollDown() { + debug("VComboBox.SP.LPS: scrollDown()"); + if (pageLength > 0 + && totalMatches > (currentPage + pagesToScroll + 1) + * pageLength) { + pagesToScroll++; + cancel(); + schedule(200); + } + } + } + + private void scroll(double deltaY) { + boolean scrollActive = menu.isScrollActive(); + + debug("VComboBox.SP: scroll() scrollActive: " + scrollActive); + + if (!scrollActive) { + if (deltaY > 0d) { + lazyPageScroller.scrollDown(); + } else { + lazyPageScroller.scrollUp(); + } + } + } + + @Override + public void onBrowserEvent(Event event) { + debug("VComboBox.SP: onBrowserEvent()"); + + if (event.getTypeInt() == Event.ONCLICK) { + final Element target = DOM.eventGetTarget(event); + if (target == up || target == DOM.getChild(up, 0)) { + lazyPageScroller.scrollUp(); + } else if (target == down || target == DOM.getChild(down, 0)) { + lazyPageScroller.scrollDown(); + } + + } + + /* + * Prevent the keyboard focus from leaving the textfield by + * preventing the default behaviour of the browser. Fixes #4285. + */ + handleMouseDownEvent(event); + } + + /** + * Should paging be enabled. If paging is enabled then only a certain + * amount of items are visible at a time and a scrollbar or buttons are + * visible to change page. If paging is turned of then all options are + * rendered into the popup menu. + * + * @param paging + * Should the paging be turned on? + */ + public void setPagingEnabled(boolean paging) { + debug("VComboBox.SP: setPagingEnabled(" + paging + ")"); + if (isPagingEnabled == paging) { + return; + } + if (paging) { + down.getStyle().clearDisplay(); + up.getStyle().clearDisplay(); + status.getStyle().clearDisplay(); + } else { + down.getStyle().setDisplay(Display.NONE); + up.getStyle().setDisplay(Display.NONE); + status.getStyle().setDisplay(Display.NONE); + } + isPagingEnabled = paging; + } + + @Override + public void setPosition(int offsetWidth, int offsetHeight) { + debug("VComboBox.SP: setPosition(" + offsetWidth + ", " + + offsetHeight + ")"); + + int top = topPosition; + int left = getPopupLeft(); + + // reset menu size and retrieve its "natural" size + menu.setHeight(""); + if (currentPage > 0 && !hasNextPage()) { + // fix height to avoid height change when getting to last page + menu.fixHeightTo(pageLength); + } + + // ignoring the parameter as in V7 + offsetHeight = getOffsetHeight(); + final int desiredHeight = offsetHeight; + final int desiredWidth = getMainWidth(); + + debug("VComboBox.SP: desired[" + desiredWidth + ", " + + desiredHeight + "]"); + + Element menuFirstChild = menu.getElement().getFirstChildElement(); + int naturalMenuWidth; + if (BrowserInfo.get().isIE() + && BrowserInfo.get().getBrowserMajorVersion() < 10) { + // On IE 8 & 9 visibility is set to hidden and measuring + // elements while they are hidden yields incorrect results + String before = menu.getElement().getParentElement().getStyle() + .getVisibility(); + menu.getElement().getParentElement().getStyle() + .setVisibility(Visibility.VISIBLE); + naturalMenuWidth = WidgetUtil.getRequiredWidth(menuFirstChild); + menu.getElement().getParentElement().getStyle() + .setProperty("visibility", before); + } else { + naturalMenuWidth = WidgetUtil.getRequiredWidth(menuFirstChild); + } + + if (popupOuterPadding == -1) { + popupOuterPadding = WidgetUtil + .measureHorizontalPaddingAndBorder(menu.getElement(), 2) + + WidgetUtil.measureHorizontalPaddingAndBorder( + suggestionPopup.getElement(), 0); + } + + updateMenuWidth(desiredWidth, naturalMenuWidth); + + if (BrowserInfo.get().isIE() + && BrowserInfo.get().getBrowserMajorVersion() < 11) { + // Must take margin,border,padding manually into account for + // menu element as we measure the element child and set width to + // the element parent + + double naturalMenuOuterWidth; + if (BrowserInfo.get().getBrowserMajorVersion() < 10) { + // On IE 8 & 9 visibility is set to hidden and measuring + // elements while they are hidden yields incorrect results + String before = menu.getElement().getParentElement() + .getStyle().getVisibility(); + menu.getElement().getParentElement().getStyle() + .setVisibility(Visibility.VISIBLE); + naturalMenuOuterWidth = WidgetUtil + .getRequiredWidthDouble(menuFirstChild) + + getMarginBorderPaddingWidth(menu.getElement()); + menu.getElement().getParentElement().getStyle() + .setProperty("visibility", before); + } else { + naturalMenuOuterWidth = WidgetUtil + .getRequiredWidthDouble(menuFirstChild) + + getMarginBorderPaddingWidth(menu.getElement()); + } + + /* + * IE requires us to specify the width for the container + * element. Otherwise it will be 100% wide + */ + double rootWidth = Math.max(desiredWidth - popupOuterPadding, + naturalMenuOuterWidth); + getContainerElement().getStyle().setWidth(rootWidth, Unit.PX); + } + + final int textInputHeight = VComboBox.this.getOffsetHeight(); + final int textInputTopOnPage = tb.getAbsoluteTop(); + final int viewportOffset = Document.get().getScrollTop(); + final int textInputTopInViewport = textInputTopOnPage + - viewportOffset; + final int textInputBottomInViewport = textInputTopInViewport + + textInputHeight; + + final int spaceAboveInViewport = textInputTopInViewport; + final int spaceBelowInViewport = Window.getClientHeight() + - textInputBottomInViewport; + + if (spaceBelowInViewport < offsetHeight + && spaceBelowInViewport < spaceAboveInViewport) { + // popup on top of input instead + if (offsetHeight > spaceAboveInViewport) { + // Shrink popup height to fit above + offsetHeight = spaceAboveInViewport; + } + top = textInputTopOnPage - offsetHeight; + } else { + // Show below, position calculated in showSuggestions for some + // strange reason + top = topPosition; + offsetHeight = Math.min(offsetHeight, spaceBelowInViewport); + } + + // fetch real width (mac FF bugs here due GWT popups overflow:auto ) + offsetWidth = menuFirstChild.getOffsetWidth(); + + if (offsetHeight < desiredHeight) { + int menuHeight = offsetHeight; + if (isPagingEnabled) { + menuHeight -= up.getOffsetHeight() + down.getOffsetHeight() + + status.getOffsetHeight(); + } else { + final ComputedStyle s = new ComputedStyle( + menu.getElement()); + menuHeight -= s.getIntProperty("marginBottom") + + s.getIntProperty("marginTop"); + } + + // If the available page height is really tiny then this will be + // negative and an exception will be thrown on setHeight. + int menuElementHeight = menu.getItemOffsetHeight(); + if (menuHeight < menuElementHeight) { + menuHeight = menuElementHeight; + } + + menu.setHeight(menuHeight + "px"); + + if (suggestionPopupWidth == null) { + final int naturalMenuWidthPlusScrollBar = naturalMenuWidth + + WidgetUtil.getNativeScrollbarSize(); + if (offsetWidth < naturalMenuWidthPlusScrollBar) { + menu.setWidth(naturalMenuWidthPlusScrollBar + "px"); + } + } + } + + if (offsetWidth + left > Window.getClientWidth()) { + left = VComboBox.this.getAbsoluteLeft() + + VComboBox.this.getOffsetWidth() - offsetWidth; + if (left < 0) { + left = 0; + menu.setWidth(Window.getClientWidth() + "px"); + + } + } + + setPopupPosition(left, top); + menu.scrollSelectionIntoView(); + } + + /** + * Adds in-line CSS rules to the DOM according to the + * suggestionPopupWidth field + * + * @param desiredWidth + * @param naturalMenuWidth + */ + private void updateMenuWidth(final int desiredWidth, + int naturalMenuWidth) { + /** + * Three different width modes for the suggestion pop-up: + * + * 1. Legacy "null"-mode: width is determined by the longest item + * caption for each page while still maintaining minimum width of + * (desiredWidth - popupOuterPadding) + * + * 2. relative to the component itself + * + * 3. fixed width + */ + String width = "auto"; + if (suggestionPopupWidth == null) { + if (naturalMenuWidth < desiredWidth) { + naturalMenuWidth = desiredWidth - popupOuterPadding; + width = (desiredWidth - popupOuterPadding) + "px"; + } + } else if (isrelativeUnits(suggestionPopupWidth)) { + float mainComponentWidth = desiredWidth - popupOuterPadding; + // convert percentage value to fraction + int widthInPx = Math.round( + mainComponentWidth * asFraction(suggestionPopupWidth)); + width = widthInPx + "px"; + } else { + // use as fixed width CSS definition + width = WidgetUtil.escapeAttribute(suggestionPopupWidth); + } + menu.setWidth(width); + } + + /** + * Returns the percentage value as a fraction, e.g. 42% -> 0.42 + * + * @param percentage + */ + private float asFraction(String percentage) { + String trimmed = percentage.trim(); + String withoutPercentSign = trimmed.substring(0, + trimmed.length() - 1); + float asFraction = Float.parseFloat(withoutPercentSign) / 100; + return asFraction; + } + + /** + * @since 7.7 + * @param suggestionPopupWidth + * @return + */ + private boolean isrelativeUnits(String suggestionPopupWidth) { + return suggestionPopupWidth.trim().endsWith("%"); + } + + /** + * Was the popup just closed? + * + * @return true if popup was just closed + */ + public boolean isJustClosed() { + debug("VComboBox.SP: justClosed()"); + final long now = (new Date()).getTime(); + return (lastAutoClosed > 0 && (now - lastAutoClosed) < 200); + } + + /* + * (non-Javadoc) + * + * @see + * com.google.gwt.event.logical.shared.CloseHandler#onClose(com.google + * .gwt.event.logical.shared.CloseEvent) + */ + + @Override + public void onClose(CloseEvent<PopupPanel> event) { + if (enableDebug) { + debug("VComboBox.SP: onClose(" + event.isAutoClosed() + ")"); + } + if (event.isAutoClosed()) { + lastAutoClosed = (new Date()).getTime(); + } + } + + /** + * Updates style names in suggestion popup to help theme building. + * + * @param componentState + * shared state of the combo box + */ + public void updateStyleNames(AbstractComponentState componentState) { + debug("VComboBox.SP: updateStyleNames()"); + setStyleName( + VComboBox.this.getStylePrimaryName() + "-suggestpopup"); + menu.setStyleName( + VComboBox.this.getStylePrimaryName() + "-suggestmenu"); + status.setClassName( + VComboBox.this.getStylePrimaryName() + "-status"); + if (ComponentStateUtil.hasStyles(componentState)) { + for (String style : componentState.styles) { + if (!"".equals(style)) { + addStyleDependentName(style); + } + } + } + } + + } + + /** + * The menu where the suggestions are rendered + */ + public class SuggestionMenu extends MenuBar + implements SubPartAware, LoadHandler { + + private VLazyExecutor delayedImageLoadExecutioner = new VLazyExecutor( + 100, new ScheduledCommand() { + + @Override + public void execute() { + debug("VComboBox.SM: delayedImageLoadExecutioner()"); + if (suggestionPopup.isVisible() + && suggestionPopup.isAttached()) { + setWidth(""); + getElement().getFirstChildElement().getStyle() + .clearWidth(); + suggestionPopup + .setPopupPositionAndShow(suggestionPopup); + } + + } + }); + + /** + * Default constructor + */ + SuggestionMenu() { + super(true); + debug("VComboBox.SM: constructor()"); + addDomHandler(this, LoadEvent.getType()); + + setScrollEnabled(true); + } + + /** + * Fixes menus height to use same space as full page would use. Needed + * to avoid height changes when quickly "scrolling" to last page. + */ + public void fixHeightTo(int pageItemsCount) { + setHeight(getPreferredHeight(pageItemsCount)); + } + + /* + * Gets the preferred height of the menu including pageItemsCount items. + */ + String getPreferredHeight(int pageItemsCount) { + if (currentSuggestions.size() > 0) { + final int pixels = (getPreferredHeight() + / currentSuggestions.size()) * pageItemsCount; + return pixels + "px"; + } else { + return ""; + } + } + + /** + * Sets the suggestions rendered in the menu. + * + * @param suggestions + * The suggestions to be rendered in the menu + */ + public void setSuggestions(Collection<ComboBoxSuggestion> suggestions) { + if (enableDebug) { + debug("VComboBox.SM: setSuggestions(" + suggestions + ")"); + } + + clearItems(); + final Iterator<ComboBoxSuggestion> it = suggestions.iterator(); + boolean isFirstIteration = true; + while (it.hasNext()) { + final ComboBoxSuggestion s = it.next(); + final MenuItem mi = new MenuItem(s.getDisplayString(), true, s); + String style = s.getStyle(); + if (style != null) { + mi.addStyleName("v-filterselect-item-" + style); + } + Roles.getListitemRole().set(mi.getElement()); + + WidgetUtil.sinkOnloadForImages(mi.getElement()); + + this.addItem(mi); + + // By default, first item on the list is always highlighted, + // unless adding new items is allowed. + if (isFirstIteration && !allowNewItems) { + selectItem(mi); + } + + if (currentSuggestion != null && s.getOptionKey() + .equals(currentSuggestion.getOptionKey())) { + // refresh also selected caption and icon in case they have + // been updated on the server + if (currentSuggestion.getReplacementString() + .equals(tb.getText())) { + currentSuggestion = s; + selectItem(mi); + setSelectedCaption( + currentSuggestion.getReplacementString()); + setSelectedItemIcon(currentSuggestion.getIconUri()); + } + } + + isFirstIteration = false; + } + } + + /** + * Create/select a suggestion based on the used entered string. This + * method is called after filtering has completed with the given string. + * + * @param enteredItemValue + * user entered string + */ + public void actOnEnteredValueAfterFiltering(String enteredItemValue) { + debug("VComboBox.SM: doPostFilterSelectedItemAction()"); + final MenuItem item = getSelectedItem(); + + // check for exact match in menu + int p = getItems().size(); + if (p > 0) { + for (int i = 0; i < p; i++) { + final MenuItem potentialExactMatch = getItems().get(i); + if (potentialExactMatch.getText() + .equals(enteredItemValue)) { + selectItem(potentialExactMatch); + // do not send a value change event if null was and + // stays selected + if (!"".equals(enteredItemValue) + || (selectedOptionKey != null + && !"".equals(selectedOptionKey))) { + doItemAction(potentialExactMatch, true); + } + suggestionPopup.hide(); + return; + } + } + } + if ("".equals(enteredItemValue) && nullSelectionAllowed) { + onNullSelected(); + } else if (allowNewItems) { + if (!enteredItemValue.equals(lastNewItemString)) { + // Store last sent new item string to avoid double sends + lastNewItemString = enteredItemValue; + connector.sendNewItem(enteredItemValue); + // TODO try to select the new value if it matches what was + // sent for V7 compatibility + } + } else if (item != null && !"".equals(lastFilter) && (item.getText() + .toLowerCase().contains(lastFilter.toLowerCase()))) { + doItemAction(item, true); + } else { + // currentSuggestion has key="" for nullselection + if (currentSuggestion != null + && !currentSuggestion.key.equals("")) { + // An item (not null) selected + String text = currentSuggestion.getReplacementString(); + setText(text); + selectedOptionKey = currentSuggestion.key; + } else { + onNullSelected(); + } + } + suggestionPopup.hide(); + } + + private static final String SUBPART_PREFIX = "item"; + + @Override + public com.google.gwt.user.client.Element getSubPartElement( + String subPart) { + int index = Integer + .parseInt(subPart.substring(SUBPART_PREFIX.length())); + + MenuItem item = getItems().get(index); + + return item.getElement(); + } + + @Override + public String getSubPartName( + com.google.gwt.user.client.Element subElement) { + if (!getElement().isOrHasChild(subElement)) { + return null; + } + + Element menuItemRoot = subElement; + while (menuItemRoot != null + && !menuItemRoot.getTagName().equalsIgnoreCase("td")) { + menuItemRoot = menuItemRoot.getParentElement().cast(); + } + // "menuItemRoot" is now the root of the menu item + + final int itemCount = getItems().size(); + for (int i = 0; i < itemCount; i++) { + if (getItems().get(i).getElement() == menuItemRoot) { + String name = SUBPART_PREFIX + i; + return name; + } + } + return null; + } + + @Override + public void onLoad(LoadEvent event) { + debug("VComboBox.SM: onLoad()"); + // Handle icon onload events to ensure shadow is resized + // correctly + delayedImageLoadExecutioner.trigger(); + + } + + /** + * @deprecated use {@link SuggestionPopup#selectFirstItem()} instead. + */ + @Deprecated + public void selectFirstItem() { + debug("VComboBox.SM: selectFirstItem()"); + MenuItem firstItem = getItems().get(0); + selectItem(firstItem); + } + + /** + * @deprecated use {@link SuggestionPopup#selectLastItem()} instead. + */ + @Deprecated + public void selectLastItem() { + debug("VComboBox.SM: selectLastItem()"); + List<MenuItem> items = getItems(); + MenuItem lastItem = items.get(items.size() - 1); + selectItem(lastItem); + } + + /* + * Gets the height of one menu item. + */ + int getItemOffsetHeight() { + List<MenuItem> items = getItems(); + return items != null && items.size() > 0 + ? items.get(0).getOffsetHeight() : 0; + } + + /* + * Gets the width of one menu item. + */ + int getItemOffsetWidth() { + List<MenuItem> items = getItems(); + return items != null && items.size() > 0 + ? items.get(0).getOffsetWidth() : 0; + } + + /** + * Returns true if the scroll is active on the menu element or if the + * menu currently displays the last page with less items then the + * maximum visibility (in which case the scroll is not active, but the + * scroll is active for any other page in general). + * + * @since 7.2.6 + */ + @Override + public boolean isScrollActive() { + String height = getElement().getStyle().getHeight(); + String preferredHeight = getPreferredHeight(pageLength); + + return !(height == null || height.length() == 0 + || height.equals(preferredHeight)); + } + + /** + * Highlight (select) an item matching the current text box content + * without triggering its action. + */ + public void highlightSelectedItem() { + int p = getItems().size(); + // first check if there is a key match to handle items with + // identical captions + String currentKey = currentSuggestion != null + ? currentSuggestion.getOptionKey() : ""; + for (int i = 0; i < p; i++) { + final MenuItem potentialExactMatch = getItems().get(i); + if (currentKey.equals(getSuggestionKey(potentialExactMatch)) + && tb.getText().equals(potentialExactMatch.getText())) { + selectItem(potentialExactMatch); + tb.setSelectionRange(tb.getText().length(), 0); + return; + } + } + // then check for exact string match in menu + String text = tb.getText(); + for (int i = 0; i < p; i++) { + final MenuItem potentialExactMatch = getItems().get(i); + if (potentialExactMatch.getText().equals(text)) { + selectItem(potentialExactMatch); + tb.setSelectionRange(tb.getText().length(), 0); + return; + } + } + } + } + + private String getSuggestionKey(MenuItem item) { + if (item != null && item.getCommand() != null) { + return ((ComboBoxSuggestion) item.getCommand()).getOptionKey(); + } + return ""; + } + + /** + * TextBox variant used as input element for filter selects, which prevents + * selecting text when disabled. + * + * @since 7.1.5 + */ + public class FilterSelectTextBox extends TextBox { + + /** + * Creates a new filter select text box. + * + * @since 7.6.4 + */ + public FilterSelectTextBox() { + /*- + * Stop the browser from showing its own suggestion popup. + * + * Using an invalid value instead of "off" as suggested by + * https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion + * + * Leaving the non-standard Safari options autocapitalize and + * autocorrect untouched since those do not interfere in the same + * way, and they might be useful in a combo box where new items are + * allowed. + */ + getElement().setAttribute("autocomplete", "nope"); + } + + /** + * Overridden to avoid selecting text when text input is disabled + */ + @Override + public void setSelectionRange(int pos, int length) { + if (textInputEnabled) { + /* + * set selection range with a backwards direction: anchor at the + * back, focus at the front. This means that items that are too + * long to display will display from the start and not the end + * even on Firefox. + * + * We need the JSNI function to set selection range so that we + * can use the optional direction attribute to set the anchor to + * the end and the focus to the start. This makes Firefox work + * the same way as other browsers (#13477) + */ + WidgetUtil.setSelectionRange(getElement(), pos, length, + "backward"); + + } else { + /* + * Setting the selectionrange for an uneditable textbox leads to + * unwanted behaviour when the width of the textbox is narrower + * than the width of the entry: the end of the entry is shown + * instead of the beginning. (see #13477) + * + * To avoid this, we set the caret to the beginning of the line. + */ + + super.setSelectionRange(0, 0); + } + } + + } + + /** + * Handler receiving notifications from the connector and updating the + * widget state accordingly. + * + * This class is still subject to change and should not be considered as + * public stable API. + * + * @since + */ + public class DataReceivedHandler { + + private Runnable navigationCallback = null; + /** + * Set true when popupopened has been clicked. Cleared on each + * UIDL-update. This handles the special case where are not filtering + * yet and the selected value has changed on the server-side. See #2119 + * <p> + * For internal use only. May be removed or replaced in the future. + */ + private boolean popupOpenerClicked = false; + /** For internal use only. May be removed or replaced in the future. */ + private boolean waitingForFilteringResponse = false; + private boolean initialData = true; + private String pendingUserInput = null; + private boolean showPopup = false; + + /** + * Called by the connector when new data for the last requested filter + * is received from the server. + */ + public void dataReceived() { + if (initialData) { + suggestionPopup.menu.setSuggestions(currentSuggestions); + performSelection(serverSelectedKey, true, true); + updateSuggestionPopupMinWidth(); + updateRootWidth(); + initialData = false; + return; + } + + suggestionPopup.menu.setSuggestions(currentSuggestions); + if (!waitingForFilteringResponse && suggestionPopup.isAttached()) { + showPopup = true; + } + if (showPopup) { + suggestionPopup.showSuggestions(currentPage, totalMatches); + } + + waitingForFilteringResponse = false; + + if (pendingUserInput != null) { + suggestionPopup.menu + .actOnEnteredValueAfterFiltering(pendingUserInput); + pendingUserInput = null; + } else if (popupOpenerClicked) { + // make sure the current item is selected in the popup + suggestionPopup.menu.highlightSelectedItem(); + } else { + navigateItemAfterPageChange(); + } + + if (!showPopup) { + suggestionPopup.hide(); + } + + popupOpenerClicked = false; + showPopup = false; + } + + /** + * Perform filtering with the user entered string and when the results + * are received, perform any action appropriate for the user input + * (select an item or create a new one). + * + * @param value + * user input + */ + public void reactOnInputWhenReady(String value) { + pendingUserInput = value; + showPopup = false; + filterOptions(0, value); + } + + /* + * This method navigates to the proper item in the combobox page. This + * should be executed after setSuggestions() method which is called from + * VComboBox.showSuggestions(). ShowSuggestions() method builds the page + * content. As far as setSuggestions() method is called as deferred, + * navigateItemAfterPageChange method should be also be called as + * deferred. #11333 + */ + private void navigateItemAfterPageChange() { + if (navigationCallback != null) { + // navigationCallback is not reset here but after any server + // request in case you are in between two requests both changing + // the page back and forth + + // we're paging w/ arrows + navigationCallback.run(); + navigationCallback = null; + } + } + + /** + * Called by the connector any pending navigation operations should be + * cleared. + */ + public void clearPendingNavigation() { + navigationCallback = null; + } + + /** + * Set a callback that is invoked when a page change occurs if there + * have not been intervening requests to the server. The callback is + * reset when any additional request is made to the server. + * + * @param callback + * method to call after filtering has completed + */ + public void setNavigationCallback(Runnable callback) { + showPopup = true; + navigationCallback = callback; + } + + /** + * Record that the popup opener has been clicked and the popup should be + * opened on the next request. + * + * This handles the special case where are not filtering yet and the + * selected value has changed on the server-side. See #2119. The flag is + * cleared on each server reply. + */ + public void popupOpenerClicked() { + popupOpenerClicked = true; + showPopup = true; + } + + /** + * Cancel a pending request to perform post-filtering actions. + */ + private void cancelPendingPostFiltering() { + pendingUserInput = null; + } + + /** + * Called by the connector when it has finished handling any reply from + * the server, regardless of what was updated. + */ + public void serverReplyHandled() { + popupOpenerClicked = false; + lastNewItemString = null; + + // if (!initDone) { + // debug("VComboBox: init done, updating widths"); + // // Calculate minimum textarea width + // updateSuggestionPopupMinWidth(); + // updateRootWidth(); + // initDone = true; + // } + } + + /** + * For internal use only - this method will be removed in the future. + * + * @return true if the combo box is waiting for a reply from the server + * with a new page of data, false otherwise + */ + public boolean isWaitingForFilteringResponse() { + return waitingForFilteringResponse; + } + + /** + * For internal use only - this method will be removed in the future. + * + * @return true if the combo box is waiting for initial data from the + * server, false otherwise + */ + public boolean isWaitingForInitialData() { + return initialData; + } + + /** + * Set a flag that filtering of options is pending a response from the + * server. + */ + private void startWaitingForFilteringResponse() { + waitingForFilteringResponse = true; + } + + /** + * Perform selection (if appropriate) based on a reply from the server. + * When this method is called, the suggestions have been reset if new + * ones (different from the previous list) were received from the + * server. + * + * @param selectedKey + * new selected key or null if none given by the server + * @param selectedCaption + * new selected item caption if sent by the server or null - + * this is used when the selected item is not on the current + * page + */ + public void updateSelectionFromServer(String selectedKey, + String selectedCaption) { + boolean oldSuggestionTextMatchTheOldSelection = currentSuggestion != null + && currentSuggestion.getReplacementString() + .equals(tb.getText()); + + serverSelectedKey = selectedKey; + + performSelection(selectedKey, oldSuggestionTextMatchTheOldSelection, + !isWaitingForFilteringResponse() || popupOpenerClicked); + + cancelPendingPostFiltering(); + + setSelectedCaption(selectedCaption); + if (currentSuggestion != null && serverSelectedKey + .equals(currentSuggestion.getOptionKey())) { + setSelectedItemIcon(currentSuggestion.getIconUri()); + } + } + + } + + // TODO decide whether this should change - affects themes and v7 + public static final String CLASSNAME = "v-filterselect"; + private static final String STYLE_NO_INPUT = "no-input"; + + /** For internal use only. May be removed or replaced in the future. */ + public int pageLength = 10; + + private boolean enableDebug = false; + + private final FlowPanel panel = new FlowPanel(); + + /** + * The text box where the filter is written + * <p> + * For internal use only. May be removed or replaced in the future. + */ + public final TextBox tb; + + /** For internal use only. May be removed or replaced in the future. */ + public final SuggestionPopup suggestionPopup; + + /** + * Used when measuring the width of the popup + */ + private final HTML popupOpener = new HTML(""); + + private class IconWidget extends Widget { + IconWidget(Icon icon) { + setElement(icon.getElement()); + } + } + + private IconWidget selectedItemIcon; + + /** For internal use only. May be removed or replaced in the future. */ + public ComboBoxConnector connector; + + /** For internal use only. May be removed or replaced in the future. */ + public int currentPage; + + /** + * A collection of available suggestions (options) as received from the + * server. + * <p> + * For internal use only. May be removed or replaced in the future. + */ + public final List<ComboBoxSuggestion> currentSuggestions = new ArrayList<ComboBoxSuggestion>(); + + /** For internal use only. May be removed or replaced in the future. */ + public String serverSelectedKey; + /** For internal use only. May be removed or replaced in the future. */ + public String selectedOptionKey; + + /** For internal use only. May be removed or replaced in the future. */ + public boolean initDone = false; + + /** For internal use only. May be removed or replaced in the future. */ + public String lastFilter = ""; + + /** + * The current suggestion selected from the dropdown. This is one of the + * values in currentSuggestions except when filtering, in this case + * currentSuggestion might not be in currentSuggestions. + * <p> + * For internal use only. May be removed or replaced in the future. + */ + public ComboBoxSuggestion currentSuggestion; + + /** For internal use only. May be removed or replaced in the future. */ + public boolean allowNewItems; + + /** For internal use only. May be removed or replaced in the future. */ + public int totalMatches; + + /** For internal use only. May be removed or replaced in the future. */ + public boolean nullSelectionAllowed; + + /** For internal use only. May be removed or replaced in the future. */ + public boolean nullSelectItem; + + /** For internal use only. May be removed or replaced in the future. */ + public boolean enabled; + + /** For internal use only. May be removed or replaced in the future. */ + public boolean readonly; + + /** For internal use only. May be removed or replaced in the future. */ + public String inputPrompt = ""; + + /** For internal use only. May be removed or replaced in the future. */ + public int suggestionPopupMinWidth = 0; + + public String suggestionPopupWidth = null; + + private int popupWidth = -1; + /** + * Stores the last new item string to avoid double submissions. Cleared on + * uidl updates. + * <p> + * For internal use only. May be removed or replaced in the future. + */ + public String lastNewItemString; + + /** For internal use only. May be removed or replaced in the future. */ + public boolean focused = false; + + /** + * If set to false, the component should not allow entering text to the + * field even for filtering. + */ + private boolean textInputEnabled = true; + + private final DataReceivedHandler dataReceivedHandler = new DataReceivedHandler(); + + /** + * Default constructor. + */ + public VComboBox() { + tb = createTextBox(); + suggestionPopup = createSuggestionPopup(); + + popupOpener.addMouseDownHandler(VComboBox.this); + Roles.getButtonRole().setAriaHiddenState(popupOpener.getElement(), + true); + Roles.getButtonRole().set(popupOpener.getElement()); + + panel.add(tb); + panel.add(popupOpener); + initWidget(panel); + Roles.getComboboxRole().set(panel.getElement()); + + tb.addKeyDownHandler(this); + tb.addKeyUpHandler(this); + + tb.addFocusHandler(this); + tb.addBlurHandler(this); + + panel.addDomHandler(this, ClickEvent.getType()); + + setStyleName(CLASSNAME); + + sinkEvents(Event.ONPASTE); + } + + private static double getMarginBorderPaddingWidth(Element element) { + final ComputedStyle s = new ComputedStyle(element); + return s.getMarginWidth() + s.getBorderWidth() + s.getPaddingWidth(); + + } + + /* + * (non-Javadoc) + * + * @see + * com.google.gwt.user.client.ui.Composite#onBrowserEvent(com.google.gwt + * .user.client.Event) + */ + @Override + public void onBrowserEvent(Event event) { + super.onBrowserEvent(event); + + if (event.getTypeInt() == Event.ONPASTE) { + if (textInputEnabled) { + filterOptions(currentPage); + } + } + } + + /** + * This method will create the TextBox used by the VComboBox instance. It is + * invoked during the Constructor and should only be overridden if a custom + * TextBox shall be used. The overriding method cannot use any instance + * variables. + * + * @since 7.1.5 + * @return TextBox instance used by this VComboBox + */ + protected TextBox createTextBox() { + return new FilterSelectTextBox(); + } + + /** + * This method will create the SuggestionPopup used by the VComboBox + * instance. It is invoked during the Constructor and should only be + * overridden if a custom SuggestionPopup shall be used. The overriding + * method cannot use any instance variables. + * + * @since 7.1.5 + * @return SuggestionPopup instance used by this VComboBox + */ + protected SuggestionPopup createSuggestionPopup() { + return new SuggestionPopup(); + } + + @Override + public void setStyleName(String style) { + super.setStyleName(style); + updateStyleNames(); + } + + @Override + public void setStylePrimaryName(String style) { + super.setStylePrimaryName(style); + updateStyleNames(); + } + + protected void updateStyleNames() { + tb.setStyleName(getStylePrimaryName() + "-input"); + popupOpener.setStyleName(getStylePrimaryName() + "-button"); + suggestionPopup.setStyleName(getStylePrimaryName() + "-suggestpopup"); + } + + /** + * Does the Select have more pages? + * + * @return true if a next page exists, else false if the current page is the + * last page + */ + public boolean hasNextPage() { + return (pageLength > 0 + && totalMatches > (currentPage + 1) * pageLength); + } + + /** + * Filters the options at a certain page. Uses the text box input as a + * filter and ensures the popup is opened when filtering results are + * available. + * + * @param page + * The page which items are to be filtered + */ + public void filterOptions(int page) { + dataReceivedHandler.popupOpenerClicked(); + filterOptions(page, tb.getText()); + } + + /** + * Filters the options at certain page using the given filter. + * + * @param page + * The page to filter + * @param filter + * The filter to apply to the components + */ + public void filterOptions(int page, String filter) { + debug("VComboBox: filterOptions(" + page + ", " + filter + ")"); + + if (filter.equals(lastFilter) && currentPage == page) { + if (!suggestionPopup.isAttached()) { + dataReceivedHandler.startWaitingForFilteringResponse(); + connector.requestPage(currentPage); + } else { + // already have the page + dataReceivedHandler.dataReceived(); + } + return; + } + if (!filter.equals(lastFilter)) { + // when filtering, let the server decide the page unless we've + // set the filter to empty and explicitly said that we want to see + // the results starting from page 0. + if ("".equals(filter) && page != 0) { + // let server decide + page = -1; + } else { + page = 0; + } + } + + dataReceivedHandler.startWaitingForFilteringResponse(); + connector.setFilter(filter); + connector.requestPage(currentPage); + + lastFilter = filter; + currentPage = page; + } + + /** For internal use only. May be removed or replaced in the future. */ + public void updateReadOnly() { + debug("VComboBox: updateReadOnly()"); + tb.setReadOnly(readonly || !textInputEnabled); + } + + public void setTextInputAllowed(boolean textInputAllowed) { + debug("VComboBox: setTextInputAllowed()"); + // Always update styles as they might have been overwritten + if (textInputAllowed) { + removeStyleDependentName(STYLE_NO_INPUT); + Roles.getTextboxRole().removeAriaReadonlyProperty(tb.getElement()); + } else { + addStyleDependentName(STYLE_NO_INPUT); + Roles.getTextboxRole().setAriaReadonlyProperty(tb.getElement(), + true); + } + + if (textInputEnabled == textInputAllowed) { + return; + } + + textInputEnabled = textInputAllowed; + updateReadOnly(); + } + + /** + * Sets the text in the text box. + * + * @param text + * the text to set in the text box + */ + public void setText(final String text) { + /** + * To leave caret in the beginning of the line. SetSelectionRange + * wouldn't work on IE (see #13477) + */ + Direction previousDirection = tb.getDirection(); + tb.setDirection(Direction.RTL); + tb.setText(text); + tb.setDirection(previousDirection); + } + + /** + * Set or reset the placeholder attribute for the text field. + * + * @param placeholder + * new placeholder string or null for none + */ + public void setPlaceholder(String placeholder) { + inputPrompt = placeholder; + updatePlaceholder(); + } + + /** + * Update placeholder visibility (hidden when read-only or disabled). + */ + public void updatePlaceholder() { + if (inputPrompt != null && enabled && !readonly) { + tb.getElement().setAttribute("placeholder", inputPrompt); + } else { + tb.getElement().removeAttribute("placeholder"); + } + } + + /** + * Triggered when a suggestion is selected. + * + * @param suggestion + * The suggestion that just got selected. + */ + public void onSuggestionSelected(ComboBoxSuggestion suggestion) { + if (enableDebug) { + debug("VComboBox: onSuggestionSelected(" + suggestion.caption + ": " + + suggestion.key + ")"); + } + // special handling of null selection + if (suggestion.key.equals("")) { + onNullSelected(); + return; + } + + dataReceivedHandler.cancelPendingPostFiltering(); + + currentSuggestion = suggestion; + String newKey = suggestion.getOptionKey(); + + String text = suggestion.getReplacementString(); + setText(text); + setSelectedItemIcon(suggestion.getIconUri()); + + if (!(newKey.equals(selectedOptionKey))) { + selectedOptionKey = newKey; + connector.sendSelection(selectedOptionKey); + setSelectedCaption(text); + + // currentPage = -1; // forget the page + } + + suggestionPopup.hide(); + } + + /** + * Triggered when an empty value is selected and null selection is allowed. + */ + public void onNullSelected() { + if (enableDebug) { + debug("VComboBox: onNullSelected()"); + } + // TODO do we need this feature? + if (nullSelectItem) { + reset(); + return; + } + dataReceivedHandler.cancelPendingPostFiltering(); + + currentSuggestion = null; + setText(""); + setSelectedItemIcon(null); + + if (!"".equals(selectedOptionKey) || (selectedOptionKey != null)) { + selectedOptionKey = ""; + setSelectedCaption(""); + connector.sendSelection(null); + // currentPage = 0; + } + + suggestionPopup.hide(); + } + + /** + * Sets the icon URI of the selected item. The icon is shown on the left + * side of the item caption text. Set the URI to null to remove the icon. + * + * @param iconUri + * The URI of the icon + */ + public void setSelectedItemIcon(String iconUri) { + + if (selectedItemIcon != null) { + panel.remove(selectedItemIcon); + } + if (iconUri == null || iconUri.length() == 0) { + if (selectedItemIcon != null) { + selectedItemIcon = null; + afterSelectedItemIconChange(); + } + } else { + selectedItemIcon = new IconWidget( + connector.getConnection().getIcon(iconUri)); + selectedItemIcon.addDomHandler(VComboBox.this, + ClickEvent.getType()); + selectedItemIcon.addDomHandler(VComboBox.this, + MouseDownEvent.getType()); + iconUpdating = true; + selectedItemIcon.addDomHandler(new LoadHandler() { + @Override + public void onLoad(LoadEvent event) { + afterSelectedItemIconChange(); + iconUpdating = false; + } + }, LoadEvent.getType()); + panel.insert(selectedItemIcon, 0); + afterSelectedItemIconChange(); + } + } + + private void afterSelectedItemIconChange() { + if (BrowserInfo.get().isWebkit()) { + // Some browsers need a nudge to reposition the text field + forceReflow(); + } + updateRootWidth(); + if (selectedItemIcon != null) { + updateSelectedIconPosition(); + } + } + + /** + * Perform selection based on a message from the server. + * + * The special case where the selected item is not on the current page is + * handled separately by the caller. + * + * @param selectedKey + * non-empty selected item key + * @param forceUpdateText + * true to force the text box value to match the suggestion text + * @param updatePromptAndSelectionIfMatchFound + */ + private void performSelection(String selectedKey, boolean forceUpdateText, + boolean updatePromptAndSelectionIfMatchFound) { + if (selectedKey == null || "".equals(selectedKey)) { + currentSuggestion = null; // #13217 + setSelectedItemIcon(null); + selectedOptionKey = null; + setText(""); + } + // some item selected + for (ComboBoxSuggestion suggestion : currentSuggestions) { + String suggestionKey = suggestion.getOptionKey(); + if (!suggestionKey.equals(selectedKey)) { + continue; + } + // at this point, suggestion key matches the new selection key + if (updatePromptAndSelectionIfMatchFound) { + if (!suggestionKey.equals(selectedOptionKey) || suggestion + .getReplacementString().equals(tb.getText()) + || forceUpdateText) { + // Update text field if we've got a new + // selection + // Also update if we've got the same text to + // retain old text selection behavior + // OR if selected item caption is changed. + setText(suggestion.getReplacementString()); + selectedOptionKey = suggestionKey; + } + } + currentSuggestion = suggestion; + setSelectedItemIcon(suggestion.getIconUri()); + // only a single item can be selected + break; + } + } + + private void forceReflow() { + WidgetUtil.setStyleTemporarily(tb.getElement(), "zoom", "1"); + } + + /** + * Positions the icon vertically in the middle. Should be called after the + * icon has loaded + */ + private void updateSelectedIconPosition() { + // Position icon vertically to middle + int availableHeight = 0; + availableHeight = getOffsetHeight(); + + int iconHeight = WidgetUtil.getRequiredHeight(selectedItemIcon); + int marginTop = (availableHeight - iconHeight) / 2; + selectedItemIcon.getElement().getStyle().setMarginTop(marginTop, + Unit.PX); + } + + private static Set<Integer> navigationKeyCodes = new HashSet<Integer>(); + static { + navigationKeyCodes.add(KeyCodes.KEY_DOWN); + navigationKeyCodes.add(KeyCodes.KEY_UP); + navigationKeyCodes.add(KeyCodes.KEY_PAGEDOWN); + navigationKeyCodes.add(KeyCodes.KEY_PAGEUP); + navigationKeyCodes.add(KeyCodes.KEY_ENTER); + } + + /* + * (non-Javadoc) + * + * @see + * com.google.gwt.event.dom.client.KeyDownHandler#onKeyDown(com.google.gwt + * .event.dom.client.KeyDownEvent) + */ + + @Override + public void onKeyDown(KeyDownEvent event) { + if (enabled && !readonly) { + int keyCode = event.getNativeKeyCode(); + + if (enableDebug) { + debug("VComboBox: key down: " + keyCode); + } + if (dataReceivedHandler.isWaitingForFilteringResponse() + && navigationKeyCodes.contains(keyCode) + && (!allowNewItems || keyCode != KeyCodes.KEY_ENTER)) { + /* + * Keyboard navigation events should not be handled while we are + * waiting for a response. This avoids flickering, disappearing + * items, wrongly interpreted responses and more. + */ + if (enableDebug) { + debug("Ignoring " + keyCode + + " because we are waiting for a filtering response"); + } + DOM.eventPreventDefault(DOM.eventGetCurrentEvent()); + event.stopPropagation(); + return; + } + + if (suggestionPopup.isAttached()) { + if (enableDebug) { + debug("Keycode " + keyCode + " target is popup"); + } + popupKeyDown(event); + } else { + if (enableDebug) { + debug("Keycode " + keyCode + " target is text field"); + } + inputFieldKeyDown(event); + } + } + } + + private void debug(String string) { + if (enableDebug) { + VConsole.error(string); + } + } + + /** + * Triggered when a key is pressed in the text box + * + * @param event + * The KeyDownEvent + */ + private void inputFieldKeyDown(KeyDownEvent event) { + if (enableDebug) { + debug("VComboBox: inputFieldKeyDown(" + event.getNativeKeyCode() + + ")"); + } + switch (event.getNativeKeyCode()) { + case KeyCodes.KEY_DOWN: + case KeyCodes.KEY_UP: + case KeyCodes.KEY_PAGEDOWN: + case KeyCodes.KEY_PAGEUP: + // open popup as from gadget + filterOptions(-1, ""); + tb.selectAll(); + dataReceivedHandler.popupOpenerClicked(); + break; + case KeyCodes.KEY_ENTER: + /* + * This only handles the case when new items is allowed, a text is + * entered, the popup opener button is clicked to close the popup + * and enter is then pressed (see #7560). + */ + if (!allowNewItems) { + return; + } + + if (currentSuggestion != null && tb.getText() + .equals(currentSuggestion.getReplacementString())) { + // Retain behavior from #6686 by returning without stopping + // propagation if there's nothing to do + return; + } + dataReceivedHandler.reactOnInputWhenReady(tb.getText()); + suggestionPopup.hide(); + + event.stopPropagation(); + break; + } + + } + + /** + * Triggered when a key was pressed in the suggestion popup. + * + * @param event + * The KeyDownEvent of the key + */ + private void popupKeyDown(KeyDownEvent event) { + if (enableDebug) { + debug("VComboBox: popupKeyDown(" + event.getNativeKeyCode() + ")"); + } + // Propagation of handled events is stopped so other handlers such as + // shortcut key handlers do not also handle the same events. + switch (event.getNativeKeyCode()) { + case KeyCodes.KEY_DOWN: + suggestionPopup.selectNextItem(); + + DOM.eventPreventDefault(DOM.eventGetCurrentEvent()); + event.stopPropagation(); + break; + case KeyCodes.KEY_UP: + suggestionPopup.selectPrevItem(); + + DOM.eventPreventDefault(DOM.eventGetCurrentEvent()); + event.stopPropagation(); + break; + case KeyCodes.KEY_PAGEDOWN: + selectNextPage(); + event.stopPropagation(); + break; + case KeyCodes.KEY_PAGEUP: + selectPrevPage(); + event.stopPropagation(); + break; + case KeyCodes.KEY_ESCAPE: + reset(); + DOM.eventPreventDefault(DOM.eventGetCurrentEvent()); + event.stopPropagation(); + break; + case KeyCodes.KEY_TAB: + case KeyCodes.KEY_ENTER: + + // queue this, may be cancelled by selection + int selectedIndex = suggestionPopup.menu.getSelectedIndex(); + if (!allowNewItems && selectedIndex != -1) { + onSuggestionSelected(currentSuggestions.get(selectedIndex)); + } else { + dataReceivedHandler.reactOnInputWhenReady(tb.getText()); + } + suggestionPopup.hide(); + + event.stopPropagation(); + break; + } + + } + + /* + * Show the prev page. + */ + private void selectPrevPage() { + if (currentPage > 0) { + dataReceivedHandler.setNavigationCallback( + () -> suggestionPopup.selectLastItem()); + filterOptions(currentPage - 1, lastFilter); + } + } + + /* + * Show the next page. + */ + private void selectNextPage() { + if (hasNextPage()) { + dataReceivedHandler.setNavigationCallback( + () -> suggestionPopup.selectFirstItem()); + filterOptions(currentPage + 1, lastFilter); + } + } + + /** + * Triggered when a key was depressed. + * + * @param event + * The KeyUpEvent of the key depressed + */ + @Override + public void onKeyUp(KeyUpEvent event) { + if (enableDebug) { + debug("VComboBox: onKeyUp(" + event.getNativeKeyCode() + ")"); + } + if (enabled && !readonly) { + switch (event.getNativeKeyCode()) { + case KeyCodes.KEY_ENTER: + case KeyCodes.KEY_TAB: + case KeyCodes.KEY_SHIFT: + case KeyCodes.KEY_CTRL: + case KeyCodes.KEY_ALT: + case KeyCodes.KEY_DOWN: + case KeyCodes.KEY_UP: + case KeyCodes.KEY_PAGEDOWN: + case KeyCodes.KEY_PAGEUP: + case KeyCodes.KEY_ESCAPE: + // NOP + break; + default: + if (textInputEnabled) { + // when filtering, we always want to see the results on the + // first page first. + filterOptions(0); + } + break; + } + } + } + + /** + * Resets the ComboBox to its initial state. + */ + private void reset() { + debug("VComboBox: reset()"); + if (currentSuggestion != null) { + String text = currentSuggestion.getReplacementString(); + setText(text); + setSelectedItemIcon(currentSuggestion.getIconUri()); + + selectedOptionKey = currentSuggestion.key; + + } else { + setText(""); + setSelectedItemIcon(null); + updatePlaceholder(); + + selectedOptionKey = null; + } + + lastFilter = ""; + suggestionPopup.hide(); + } + + /** + * Listener for popupopener. + */ + @Override + public void onClick(ClickEvent event) { + debug("VComboBox: onClick()"); + if (textInputEnabled && event.getNativeEvent().getEventTarget() + .cast() == tb.getElement()) { + // Don't process clicks on the text field if text input is enabled + return; + } + if (enabled && !readonly) { + // ask suggestionPopup if it was just closed, we are using GWT + // Popup's auto close feature + if (!suggestionPopup.isJustClosed()) { + filterOptions(-1, ""); + dataReceivedHandler.popupOpenerClicked(); + } + DOM.eventPreventDefault(DOM.eventGetCurrentEvent()); + focus(); + tb.selectAll(); + } + } + + /** + * Update minimum width for combo box textarea based on input prompt and + * suggestions. + * <p> + * For internal use only. May be removed or replaced in the future. + */ + public void updateSuggestionPopupMinWidth() { + debug("VComboBox: updateSuggestionPopupMinWidth()"); + + // used only to calculate minimum width + String captions = WidgetUtil.escapeHTML(inputPrompt); + + for (ComboBoxSuggestion suggestion : currentSuggestions) { + // Collect captions so we can calculate minimum width for + // textarea + if (captions.length() > 0) { + captions += "|"; + } + captions += WidgetUtil + .escapeHTML(suggestion.getReplacementString()); + } + + // Calculate minimum textarea width + suggestionPopupMinWidth = minWidth(captions); + } + + /** + * Calculate minimum width for FilterSelect textarea. + * <p> + * For internal use only. May be removed or replaced in the future. + * + * @param captions + * pipe separated string listing all the captions to measure + * @return minimum width in pixels + */ + public native int minWidth(String captions) + /*-{ + if(!captions || captions.length <= 0) + return 0; + captions = captions.split("|"); + var d = $wnd.document.createElement("div"); + var html = ""; + for(var i=0; i < captions.length; i++) { + html += "<div>" + captions[i] + "</div>"; + // TODO apply same CSS classname as in suggestionmenu + } + d.style.position = "absolute"; + d.style.top = "0"; + d.style.left = "0"; + d.style.visibility = "hidden"; + d.innerHTML = html; + $wnd.document.body.appendChild(d); + var w = d.offsetWidth; + $wnd.document.body.removeChild(d); + return w; + }-*/; + + /** + * A flag which prevents a focus event from taking place. + */ + boolean iePreventNextFocus = false; + + /* + * (non-Javadoc) + * + * @see + * com.google.gwt.event.dom.client.FocusHandler#onFocus(com.google.gwt.event + * .dom.client.FocusEvent) + */ + + @Override + public void onFocus(FocusEvent event) { + debug("VComboBox: onFocus()"); + + /* + * When we disable a blur event in ie we need to refocus the textfield. + * This will cause a focus event we do not want to process, so in that + * case we just ignore it. + */ + if (BrowserInfo.get().isIE() && iePreventNextFocus) { + iePreventNextFocus = false; + return; + } + + focused = true; + updatePlaceholder(); + addStyleDependentName("focus"); + + connector.sendFocusEvent(); + + connector.getConnection().getVTooltip() + .showAssistive(connector.getTooltipInfo(getElement())); + } + + /** + * A flag which cancels the blur event and sets the focus back to the + * textfield if the Browser is IE. + */ + boolean preventNextBlurEventInIE = false; + + private String explicitSelectedCaption; + private boolean iconUpdating = false; + + /* + * (non-Javadoc) + * + * @see + * com.google.gwt.event.dom.client.BlurHandler#onBlur(com.google.gwt.event + * .dom.client.BlurEvent) + */ + + @Override + public void onBlur(BlurEvent event) { + debug("VComboBox: onBlur()"); + + if (BrowserInfo.get().isIE() && preventNextBlurEventInIE) { + /* + * Clicking in the suggestion popup or on the popup button in IE + * causes a blur event to be sent for the field. In other browsers + * this is prevented by canceling/preventing default behavior for + * the focus event, in IE we handle it here by refocusing the text + * field and ignoring the resulting focus event for the textfield + * (in onFocus). + */ + preventNextBlurEventInIE = false; + + Element focusedElement = WidgetUtil.getFocusedElement(); + if (getElement().isOrHasChild(focusedElement) || suggestionPopup + .getElement().isOrHasChild(focusedElement)) { + + // IF the suggestion popup or another part of the VComboBox + // was focused, move the focus back to the textfield and prevent + // the triggered focus event (in onFocus). + iePreventNextFocus = true; + tb.setFocus(true); + return; + } + } + + focused = false; + updatePlaceholder(); + if (!readonly) { + reset(); + suggestionPopup.hide(); + } + removeStyleDependentName("focus"); + + connector.sendBlurEvent(); + } + + /* + * (non-Javadoc) + * + * @see com.vaadin.client.Focusable#focus() + */ + + @Override + public void focus() { + debug("VComboBox: focus()"); + focused = true; + updatePlaceholder(); + tb.setFocus(true); + } + + /** + * Calculates the width of the select if the select has undefined width. + * Should be called when the width changes or when the icon changes. + * <p> + * For internal use only. May be removed or replaced in the future. + */ + public void updateRootWidth() { + debug("VComboBox: updateRootWidth()"); + + if (connector.isUndefinedWidth()) { + + /* + * When the select has a undefined with we need to check that we are + * only setting the text box width relative to the first page width + * of the items. If this is not done the text box width will change + * when the popup is used to view longer items than the text box is + * wide. + */ + int w = WidgetUtil.getRequiredWidth(this); + + if (dataReceivedHandler.isWaitingForInitialData() + && suggestionPopupMinWidth > w) { + /* + * We want to compensate for the paddings just to preserve the + * exact size as in Vaadin 6.x, but we get here before + * MeasuredSize has been initialized. + * Util.measureHorizontalPaddingAndBorder does not work with + * border-box, so we must do this the hard way. + */ + Style style = getElement().getStyle(); + String originalPadding = style.getPadding(); + String originalBorder = style.getBorderWidth(); + style.setPaddingLeft(0, Unit.PX); + style.setBorderWidth(0, Unit.PX); + style.setProperty("padding", originalPadding); + style.setProperty("borderWidth", originalBorder); + + // Use util.getRequiredWidth instead of getOffsetWidth here + + int iconWidth = selectedItemIcon == null ? 0 + : WidgetUtil.getRequiredWidth(selectedItemIcon); + int buttonWidth = popupOpener == null ? 0 + : WidgetUtil.getRequiredWidth(popupOpener); + + /* + * Instead of setting the width of the wrapper, set the width of + * the combobox. Subtract the width of the icon and the + * popupopener + */ + + tb.setWidth((suggestionPopupMinWidth - iconWidth - buttonWidth) + + "px"); + } + + /* + * Lock the textbox width to its current value if it's not already + * locked. This can happen after setWidth("") which resets the + * textbox width to "100%". + */ + if (!tb.getElement().getStyle().getWidth().endsWith("px")) { + int iconWidth = selectedItemIcon == null ? 0 + : selectedItemIcon.getOffsetWidth(); + tb.setWidth((tb.getOffsetWidth() - iconWidth) + "px"); + } + } + } + + /** + * Get the width of the select in pixels where the text area and icon has + * been included. + * + * @return The width in pixels + */ + private int getMainWidth() { + return getOffsetWidth(); + } + + @Override + public void setWidth(String width) { + super.setWidth(width); + if (width.length() != 0) { + tb.setWidth("100%"); + } + } + + /** + * Handles special behavior of the mouse down event. + * + * @param event + */ + private void handleMouseDownEvent(Event event) { + /* + * Prevent the keyboard focus from leaving the textfield by preventing + * the default behaviour of the browser. Fixes #4285. + */ + if (event.getTypeInt() == Event.ONMOUSEDOWN) { + debug("VComboBox: blocking mouseDown event to avoid blur"); + + event.preventDefault(); + event.stopPropagation(); + + /* + * In IE the above wont work, the blur event will still trigger. So, + * we set a flag here to prevent the next blur event from happening. + * This is not needed if do not already have focus, in that case + * there will not be any blur event and we should not cancel the + * next blur. + */ + if (BrowserInfo.get().isIE() && focused) { + preventNextBlurEventInIE = true; + debug("VComboBox: Going to prevent next blur event on IE"); + } + } + } + + @Override + public void onMouseDown(MouseDownEvent event) { + debug("VComboBox.onMouseDown(): blocking mouseDown event to avoid blur"); + + event.preventDefault(); + event.stopPropagation(); + + /* + * In IE the above wont work, the blur event will still trigger. So, we + * set a flag here to prevent the next blur event from happening. This + * is not needed if do not already have focus, in that case there will + * not be any blur event and we should not cancel the next blur. + */ + if (BrowserInfo.get().isIE() && focused) { + preventNextBlurEventInIE = true; + debug("VComboBox: Going to prevent next blur event on IE"); + } + } + + @Override + protected void onDetach() { + super.onDetach(); + suggestionPopup.hide(); + } + + @Override + public com.google.gwt.user.client.Element getSubPartElement( + String subPart) { + String[] parts = subPart.split("/"); + if ("textbox".equals(parts[0])) { + return tb.getElement(); + } else if ("button".equals(parts[0])) { + return popupOpener.getElement(); + } else if ("popup".equals(parts[0]) && suggestionPopup.isAttached()) { + if (parts.length == 2) { + return suggestionPopup.menu.getSubPartElement(parts[1]); + } + return suggestionPopup.getElement(); + } + return null; + } + + @Override + public String getSubPartName( + com.google.gwt.user.client.Element subElement) { + if (tb.getElement().isOrHasChild(subElement)) { + return "textbox"; + } else if (popupOpener.getElement().isOrHasChild(subElement)) { + return "button"; + } else if (suggestionPopup.getElement().isOrHasChild(subElement)) { + return "popup"; + } + return null; + } + + @Override + public void setAriaRequired(boolean required) { + AriaHelper.handleInputRequired(tb, required); + } + + @Override + public void setAriaInvalid(boolean invalid) { + AriaHelper.handleInputInvalid(tb, invalid); + } + + @Override + public void bindAriaCaption( + com.google.gwt.user.client.Element captionElement) { + AriaHelper.bindCaption(tb, captionElement); + } + + @Override + public boolean isWorkPending() { + return dataReceivedHandler.isWaitingForFilteringResponse() + || suggestionPopup.lazyPageScroller.isRunning() || iconUpdating; + } + + /** + * Sets the caption of selected item, if "scroll to page" is disabled. This + * method is meant for internal use and may change in future versions. + * + * @since 7.7 + * @param selectedCaption + * the caption of selected item + */ + public void setSelectedCaption(String selectedCaption) { + explicitSelectedCaption = selectedCaption; + if (selectedCaption != null) { + setText(selectedCaption); + } + } + + /** + * This method is meant for internal use and may change in future versions. + * + * @since 7.7 + * @return the caption of selected item, if "scroll to page" is disabled + */ + public String getSelectedCaption() { + return explicitSelectedCaption; + } + + /** + * Returns a handler receiving notifications from the connector about + * communications. + * + * @return the dataReceivedHandler + */ + public DataReceivedHandler getDataReceivedHandler() { + return dataReceivedHandler; + } + + /** + * Sets the number of items to show per page, or 0 for showing all items. + * + * @param pageLength + * new page length or 0 for all items + */ + public void setPageLength(int pageLength) { + this.pageLength = pageLength; + } + + /** + * Sets the suggestion pop-up's width as a CSS string. By using relative + * units (e.g. "50%") it's possible to set the popup's width relative to the + * ComboBox itself. + * + * @param suggestionPopupWidth + * new popup width as CSS string, null for old default width + * calculation based on items + */ + public void setSuggestionPopupWidth(String suggestionPopupWidth) { + this.suggestionPopupWidth = suggestionPopupWidth; + } + + /** + * Sets whether creation of new items when there is no match is allowed or + * not. + * + * @param allowNewItems + * true to allow creation of new items, false to only allow + * selection of existing items + */ + public void setAllowNewItems(boolean allowNewItems) { + this.allowNewItems = allowNewItems; + } + +} diff --git a/client/src/main/java/com/vaadin/client/ui/combobox/ComboBoxConnector.java b/client/src/main/java/com/vaadin/client/ui/combobox/ComboBoxConnector.java new file mode 100644 index 0000000000..d0758b726b --- /dev/null +++ b/client/src/main/java/com/vaadin/client/ui/combobox/ComboBoxConnector.java @@ -0,0 +1,339 @@ +/* + * Copyright 2000-2016 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.client.ui.combobox; + +import com.vaadin.client.Profiler; +import com.vaadin.client.communication.RpcProxy; +import com.vaadin.client.communication.StateChangeEvent; +import com.vaadin.client.connectors.data.HasDataSource; +import com.vaadin.client.data.DataSource; +import com.vaadin.client.ui.AbstractFieldConnector; +import com.vaadin.client.ui.SimpleManagedLayout; +import com.vaadin.client.ui.VComboBox; +import com.vaadin.client.ui.VComboBox.DataReceivedHandler; +import com.vaadin.shared.EventId; +import com.vaadin.shared.Registration; +import com.vaadin.shared.communication.FieldRpc.FocusAndBlurServerRpc; +import com.vaadin.shared.data.DataCommunicatorConstants; +import com.vaadin.shared.ui.Connect; +import com.vaadin.shared.ui.combobox.ComboBoxClientRpc; +import com.vaadin.shared.ui.combobox.ComboBoxConstants; +import com.vaadin.shared.ui.combobox.ComboBoxServerRpc; +import com.vaadin.shared.ui.combobox.ComboBoxState; +import com.vaadin.ui.ComboBox; + +import elemental.json.JsonObject; + +@Connect(ComboBox.class) +public class ComboBoxConnector extends AbstractFieldConnector + implements HasDataSource, SimpleManagedLayout { + + protected ComboBoxServerRpc rpc = RpcProxy.create(ComboBoxServerRpc.class, + this); + + protected FocusAndBlurServerRpc focusAndBlurRpc = RpcProxy + .create(FocusAndBlurServerRpc.class, this); + + private DataSource<JsonObject> dataSource; + + private Registration dataChangeHandlerRegistration; + + @Override + protected void init() { + super.init(); + getWidget().connector = this; + registerRpc(ComboBoxClientRpc.class, new ComboBoxClientRpc() { + @Override + public void setSelectedItem(String selectedKey, + String selectedCaption) { + getDataReceivedHandler().updateSelectionFromServer(selectedKey, + selectedCaption); + } + }); + } + + @Override + public void onStateChanged(StateChangeEvent stateChangeEvent) { + super.onStateChanged(stateChangeEvent); + + Profiler.enter("ComboBoxConnector.onStateChanged update content"); + + getWidget().readonly = isReadOnly(); + getWidget().updateReadOnly(); + + // not a FocusWidget -> needs own tabindex handling + getWidget().tb.setTabIndex(getState().tabIndex); + + getWidget().suggestionPopup.updateStyleNames(getState()); + + getWidget().nullSelectionAllowed = getState().emptySelectionAllowed; + // TODO having this true would mean that the empty selection item comes + // from the data source so none needs to be added - currently + // unsupported + getWidget().nullSelectItem = false; + + // make sure the input prompt is updated + getWidget().updatePlaceholder(); + + getDataReceivedHandler().serverReplyHandled(); + + Profiler.leave("ComboBoxConnector.onStateChanged update content"); + } + + @Override + public VComboBox getWidget() { + return (VComboBox) super.getWidget(); + } + + private DataReceivedHandler getDataReceivedHandler() { + return getWidget().getDataReceivedHandler(); + } + + @Override + public ComboBoxState getState() { + return (ComboBoxState) super.getState(); + } + + @Override + public void layout() { + VComboBox widget = getWidget(); + if (widget.initDone) { + widget.updateRootWidth(); + } + } + + @Override + public void setWidgetEnabled(boolean widgetEnabled) { + super.setWidgetEnabled(widgetEnabled); + getWidget().enabled = widgetEnabled; + getWidget().tb.setEnabled(widgetEnabled); + } + + /* + * These methods exist to move communications out of VComboBox, and may be + * refactored/removed in the future + */ + + /** + * Send a message about a newly created item to the server. + * + * This method is for internal use only and may be removed in future + * versions. + * + * @since + * @param itemValue + * user entered string value for the new item + */ + public void sendNewItem(String itemValue) { + rpc.createNewItem(itemValue); + getDataReceivedHandler().clearPendingNavigation(); + } + + /** + * Send a message to the server set the current filter. + * + * This method is for internal use only and may be removed in future + * versions. + * + * @since + * @param filter + * the current filter string + */ + public void setFilter(String filter) { + if (filter != getWidget().lastFilter) { + getDataReceivedHandler().clearPendingNavigation(); + } + rpc.setFilter(filter); + } + + /** + * Send a message to the server to request a page of items with the current + * filter. + * + * This method is for internal use only and may be removed in future + * versions. + * + * @since + * @param page + * the page number to get or -1 to let the server/connector + * decide based on current selection (possibly loading more data + * from the server) + */ + public void requestPage(int page) { + if (page < 0) { + if (getState().scrollToSelectedItem) { + getDataSource().ensureAvailability(0, 10000); + return; + } else { + page = 0; + } + } + int adjustment = (getWidget().nullSelectionAllowed + && "".equals(getWidget().lastFilter)) ? 1 : 0; + int startIndex = Math.max(0, + page * getWidget().pageLength - adjustment); + int pageLength = getWidget().pageLength > 0 ? getWidget().pageLength + : 10000; + getDataSource().ensureAvailability(startIndex, pageLength); + } + + /** + * Send a message to the server updating the current selection. + * + * This method is for internal use only and may be removed in future + * versions. + * + * @since + * @param selectionKey + * the current selected item key + */ + public void sendSelection(String selectionKey) { + rpc.setSelectedItem(selectionKey); + getDataReceivedHandler().clearPendingNavigation(); + } + + /** + * Notify the server that the combo box received focus. + * + * For timing reasons, ConnectorFocusAndBlurHandler is not used at the + * moment. + * + * This method is for internal use only and may be removed in future + * versions. + * + * @since + */ + public void sendFocusEvent() { + boolean registeredListeners = hasEventListener(EventId.FOCUS); + if (registeredListeners) { + focusAndBlurRpc.focus(); + getDataReceivedHandler().clearPendingNavigation(); + } + } + + /** + * Notify the server that the combo box lost focus. + * + * For timing reasons, ConnectorFocusAndBlurHandler is not used at the + * moment. + * + * This method is for internal use only and may be removed in future + * versions. + * + * @since + */ + public void sendBlurEvent() { + boolean registeredListeners = hasEventListener(EventId.BLUR); + if (registeredListeners) { + focusAndBlurRpc.blur(); + getDataReceivedHandler().clearPendingNavigation(); + } + } + + @Override + public void setDataSource(DataSource<JsonObject> dataSource) { + this.dataSource = dataSource; + dataChangeHandlerRegistration = dataSource + .addDataChangeHandler(range -> { + // try to find selected item if requested + if (getState().scrollToSelectedItem + && getState().pageLength > 0 + && getWidget().currentPage < 0 + && getWidget().selectedOptionKey != null) { + // search for the item with the selected key + getWidget().currentPage = 0; + for (int i = 0; i < getDataSource().size(); ++i) { + JsonObject row = getDataSource().getRow(i); + if (row != null) { + String key = row.getString( + DataCommunicatorConstants.KEY); + if (getWidget().selectedOptionKey.equals(key)) { + if (getWidget().nullSelectionAllowed) { + getWidget().currentPage = (i + 1) + / getState().pageLength; + } else { + getWidget().currentPage = i + / getState().pageLength; + } + break; + } + } + } + } else if (getWidget().currentPage < 0) { + getWidget().currentPage = 0; + } + + getWidget().currentSuggestions.clear(); + + int start = getWidget().currentPage + * getWidget().pageLength; + int end = getWidget().pageLength > 0 + ? start + getWidget().pageLength + : getDataSource().size(); + + if (getWidget().nullSelectionAllowed + && "".equals(getWidget().lastFilter)) { + // add special null selection item... + if (getWidget().currentPage == 0) { + getWidget().currentSuggestions + .add(getWidget().new ComboBoxSuggestion("", + "", null, null)); + } else { + // ...or leave space for it + start = start - 1; + } + // in either case, the last item to show is + // shifted by one + end = end - 1; + } + + for (int i = start; i < end; ++i) { + JsonObject row = getDataSource().getRow(i); + + if (row != null) { + String key = row + .getString(DataCommunicatorConstants.KEY); + String caption = row + .getString(DataCommunicatorConstants.NAME); + String style = row + .getString(ComboBoxConstants.STYLE); + String untranslatedIconUri = row + .getString(ComboBoxConstants.ICON); + getWidget().currentSuggestions + .add(getWidget().new ComboBoxSuggestion(key, + caption, style, + untranslatedIconUri)); + } + } + getWidget().totalMatches = getDataSource().size() + + (getState().emptySelectionAllowed ? 1 : 0); + + getDataReceivedHandler().dataReceived(); + }); + } + + @Override + public void onUnregister() { + super.onUnregister(); + dataChangeHandlerRegistration.remove(); + } + + @Override + public DataSource<JsonObject> getDataSource() { + return dataSource; + } + +} |