// Copyright 2012 Google Inc. All Rights Reserved. // // 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.google.collide.client.ui.tooltip; import com.google.collide.client.common.Constants; import com.google.collide.client.ui.menu.AutoHideComponent; import com.google.collide.client.ui.menu.AutoHideView; import com.google.collide.client.ui.menu.PositionController; import com.google.collide.client.ui.menu.PositionController.HorizontalAlign; import com.google.collide.client.ui.menu.PositionController.Position; import com.google.collide.client.ui.menu.PositionController.Positioner; import com.google.collide.client.ui.menu.PositionController.PositionerBuilder; import com.google.collide.client.ui.menu.PositionController.VerticalAlign; import com.google.collide.client.util.AnimationController; import com.google.collide.client.util.Elements; import com.google.collide.client.util.HoverController.HoverListener; import com.google.collide.client.util.logging.Log; import com.google.collide.json.shared.JsonArray; import com.google.collide.shared.util.JsonCollections; import com.google.gwt.resources.client.ClientBundle; import com.google.gwt.resources.client.CssResource; import elemental.dom.Node; import elemental.events.Event; import elemental.events.EventListener; import elemental.events.EventRemover; import elemental.events.EventTarget; import elemental.events.MouseEvent; import elemental.html.Element; import elemental.util.Timer; /** * Represents a single tooltip instance attached to any element, activated by * hovering. */ /* * TODO: oh, my god this thing has become a monster. Might be nice to * get a list of requirements and start from the top... especially if we need * some coach marks as well for the landing page. */ public class Tooltip extends AutoHideComponent<AutoHideView<Void>, AutoHideComponent.AutoHideModel> { /** * A builder used to construct a new Tooltip. */ public static class Builder { private final Resources res; private final JsonArray<Element> targetElements; private final Positioner positioner; private boolean shouldShowOnHover = true; private TooltipRenderer renderer; /** * @see TooltipPositionerBuilder */ public Builder(Resources res, Element targetElement, Positioner positioner) { this.res = res; this.positioner = positioner; this.targetElements = JsonCollections.createArray(targetElement); } /** * Adds additional target elements. If the user hovers over any of the target elements, the * tooltip will appear. */ public Builder addTargetElements(Element... additionalTargets) { for (int i = 0; i < additionalTargets.length; i++) { targetElements.add(additionalTargets[i]); } return this; } /** * Sets the tooltip text. Each item in the array appears on a new line. This * method overwrites the tooltip renderer. */ public Builder setTooltipText(String... tooltipText) { return setTooltipRenderer(new SimpleStringRenderer(tooltipText)); } public Builder setTooltipRenderer(TooltipRenderer renderer) { this.renderer = renderer; return this; } /** * If false, will prevent the tooltip from automatically showing on hover. */ public Builder setShouldListenToHover(boolean shouldShowOnHover) { this.shouldShowOnHover = shouldShowOnHover; return this; } public Tooltip build() { return new Tooltip(getViewInstance(res.tooltipCss()), res, targetElements, positioner, renderer, shouldShowOnHover); } } /** * A {@link PositionerBuilder} which uses some more convenient defaults for tooltips. This builder * defaults to {@link VerticalAlign#BOTTOM} {@link HorizontalAlign#MIDDLE} and * {@link Position#NO_OVERLAP}. */ public static class TooltipPositionerBuilder extends PositionerBuilder { public TooltipPositionerBuilder() { setVerticalAlign(PositionController.VerticalAlign.BOTTOM); setHorizontalAlign(PositionController.HorizontalAlign.MIDDLE); setPosition(PositionController.Position.NO_OVERLAP); } } /** * Static factory method for creating a simple tooltip. */ public static Tooltip create(Resources res, Element targetElement, VerticalAlign vAlign, HorizontalAlign hAlign, String... tooltipText) { return new Builder(res, targetElement, new TooltipPositionerBuilder().setVerticalAlign(vAlign) .setHorizontalAlign(hAlign).buildAnchorPositioner(targetElement)).setTooltipRenderer( new SimpleStringRenderer(tooltipText)).build(); } /** * Interface for specifying an arbitrary renderer for tooltips. */ public interface TooltipRenderer { Element renderDom(); } /** * Default renderer that simply renders the tooltip text with no other DOM. */ private static class SimpleStringRenderer implements TooltipRenderer { private final String[] tooltipText; SimpleStringRenderer(String... tooltipText) { this.tooltipText = tooltipText; } @Override public Element renderDom() { Element content = Elements.createDivElement(); int i = 0; for (String p : tooltipText) { content.appendChild(Elements.createTextNode(p)); if (i < tooltipText.length - 1) { content.appendChild(Elements.createBRElement()); content.appendChild(Elements.createBRElement()); } i++; } return content; } } /** The singleton view instance that all tooltips use. */ private static AutoHideView<Void> tooltipViewInstance; /** * The currently active tooltip that is bound to the view. */ private static Tooltip activeTooltip; /** * The Tooltip is a flyweight that uses a singleton View base element. */ private static AutoHideView<Void> getViewInstance(Css css) { if (tooltipViewInstance == null) { tooltipViewInstance = new AutoHideView<Void>(Elements.createDivElement()); tooltipViewInstance.getElement().addClassName(css.tooltipPosition()); } return tooltipViewInstance; } public interface Css extends CssResource { String tooltipPosition(); String tooltip(); String triangle(); String tooltipAbove(); String tooltipRight(); String tooltipBelow(); String tooltipLeft(); String tooltipBelowRightAligned(); } public interface Resources extends ClientBundle { @Source({"com/google/collide/client/common/constants.css", "Tooltip.css"}) Css tooltipCss(); @Source({"com/google/collide/client/common/constants.css", "Coachmark.css"}) Coachmark.Css coachmarkCss(); } private static final int SHOW_DELAY = Constants.MOUSE_HOVER_DELAY; private static final int HIDE_DELAY = Constants.MOUSE_HOVER_DELAY; /** * Holds a reference to the css. */ private final Css css; private Element contentElement; private final JsonArray<Element> targetElements; private final Timer showTimer; private final TooltipRenderer renderer; private final PositionController positionController; private final JsonArray<EventRemover> eventRemovers; private final Positioner positioner; private String title; private String maxWidth; private boolean isEnabled = true; private boolean isShowDelayDisabled; private Tooltip(AutoHideView<Void> view, Resources res, JsonArray<Element> targetElements, Positioner positioner, TooltipRenderer renderer, boolean shouldShowOnHover) { super(view, new AutoHideModel()); this.positioner = positioner; this.renderer = renderer; this.css = res.tooltipCss(); this.targetElements = targetElements; this.eventRemovers = shouldShowOnHover ? attachToTargetElement() : JsonCollections.<EventRemover>createArray(); getView().setAnimationController(AnimationController.FADE_ANIMATION_CONTROLLER); positionController = new PositionController(positioner, getView().getElement()); showTimer = new Timer() { @Override public void run() { show(); } }; setDelay(HIDE_DELAY); setCaptureOutsideClickOnClose(false); getHoverController().setHoverListener(new HoverListener() { @Override public void onHover() { if (isEnabled && !isShowing()) { deferredShow(); } } }); } @Override public void show() { // Nothing to do if it is showing. if (isShowing()) { return; } /* * Hide the old Tooltip. This will not actually hide the View because we set * activeTooltip to null. */ Tooltip oldTooltip = activeTooltip; activeTooltip = null; if (oldTooltip != null) { oldTooltip.hide(); } ensureContent(); // Bind to the singleton view. getView().getElement().setInnerHTML(""); getView().getElement().appendChild(contentElement); positionController.updateElementPosition(); activeTooltip = this; super.show(); } @Override public void forceHide() { super.forceHide(); activeTooltip = null; } @Override protected void hideView() { // If another tooltip is being shown, do not hide the shared view. if (activeTooltip == this) { super.hideView(); } } public void setTitle(String title) { this.title = title; } public void setMaxWidth(String maxWidth) { this.maxWidth = maxWidth; // Update the content element if it is already created. if (contentElement != null) { if (maxWidth == null) { contentElement.getStyle().removeProperty("max-width"); } else { contentElement.getStyle().setProperty("max-width", maxWidth); } } } /** * Enables or disables the show delay. If disabled, the tooltip will appear * instantly on hover. Defaults to enabled. * * @param isDisabled true to disable the show delay */ public void setShowDelayDisabled(boolean isDisabled) { this.isShowDelayDisabled = isDisabled; } /** * Enable or disable this tooltip */ public void setEnabled(boolean isEnabled) { this.isEnabled = isEnabled; } private void setPositionStyle() { VerticalAlign vAlign = positioner.getVerticalAlignment(); HorizontalAlign hAlign = positioner.getHorizontalAlignment(); switch (positioner.getVerticalAlignment()) { case TOP: contentElement.addClassName(css.tooltipAbove()); break; case BOTTOM: if (hAlign == HorizontalAlign.RIGHT) { contentElement.addClassName(css.tooltipBelowRightAligned()); } else { contentElement.addClassName(css.tooltipBelow()); } break; case MIDDLE: if (hAlign == HorizontalAlign.LEFT) { contentElement.addClassName(css.tooltipLeft()); } else if (hAlign == HorizontalAlign.RIGHT) { contentElement.addClassName(css.tooltipRight()); } break; } } /** * Adds event handlers to the target element for the tooltip to show it on * hover, and update position on mouse move. */ private JsonArray<EventRemover> attachToTargetElement() { JsonArray<EventRemover> removers = JsonCollections.createArray(); for (int i = 0; i < targetElements.size(); i++) { final Element targetElement = targetElements.get(i); addPartner(targetElement); removers.add(targetElement.addEventListener(Event.MOUSEOUT, new EventListener() { @Override public void handleEvent(Event evt) { MouseEvent mouseEvt = (MouseEvent) evt; EventTarget relatedTarget = mouseEvt.getRelatedTarget(); // Ignore the event unless we mouse completely out of the target element. if (relatedTarget == null || !targetElement.contains((Node) relatedTarget)) { cancelPendingShow(); } } }, false)); removers.add(targetElement.addEventListener(Event.MOUSEDOWN, new EventListener() { @Override public void handleEvent(Event evt) { cancelPendingShow(); hide(); } }, false)); } return removers; } /** * Removes event handlers from the target element for the tooltip. */ private void detachFromTargetElement() { for (int i = 0; i < targetElements.size(); i++) { removePartner(targetElements.get(i)); } for (int i = 0, n = eventRemovers.size(); i < n; ++i) { eventRemovers.get(i).remove(); } eventRemovers.clear(); } /** * Creates the dom for this tooltip's content. * * <code> * <div class="tooltipPosition"> * <div class="tooltip tooltipAbove/Below/Left/Right"> * tooltipText * <div class="tooltipTriangle"></div> * </div> * </div> * </code> */ private void ensureContent() { if (contentElement == null) { contentElement = renderer.renderDom(); if (contentElement == null) { // Guard against malformed renderers. Log.warn(getClass(), "Renderer for tooltip returned a null content element"); contentElement = Elements.createDivElement(); contentElement.setTextContent("An empty Tooltip!"); } if (title != null) { // Insert a title if one is set. Element titleElem = Elements.createElement("b"); titleElem.setTextContent(title); Element breakElem = Elements.createBRElement(); contentElement.insertBefore(breakElem, contentElement.getFirstChild()); contentElement.insertBefore(titleElem, contentElement.getFirstChild()); } // Set the maximum width. setMaxWidth(maxWidth); contentElement.addClassName(css.tooltip()); Element triangle = Elements.createDivElement(css.triangle()); contentElement.appendChild(triangle); setPositionStyle(); } } public void destroy() { showTimer.cancel(); forceHide(); detachFromTargetElement(); } private void deferredShow() { if (isShowDelayDisabled || activeTooltip != null) { /* * If there is already a tooltip showing and the user mouses over an item * that has it's own tooltip, move the tooltip immediately. We don't want * to leave a lingering tooltip on the old item. */ showTimer.cancel(); showTimer.run(); } else { showTimer.schedule(SHOW_DELAY); } } private void cancelPendingShow() { showTimer.cancel(); } }