// 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.util;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.shared.util.JsonCollections;
import com.google.gwt.user.client.Timer;
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;
/**
* Controller to manage a group of elements that are hovered and unhovered
* together. For example, you can use this controller to link a button to its
* submenu.
*
* When the user mouses over any of the "partner" elements, the controller calls
* {@link HoverListener#onHover()}. When the user mouses out of all partner
* elements and does not mouse over one of the elements within a fixed delay,
* the controller calls {@link UnhoverListener#onUnhover()}.
*
* The default delay is 1300ms, but you can override this. We recommend using
* one of the static values so that similar UI components use the same delay.
*/
public class HoverController {
/**
* The default unhover delay.
*/
public static final int DEFAULT_UNHOVER_DELAY = 1300;
/**
* The unhover delay used for dropdown UI components, such as button menus.
*/
public static final int DROP_DOWN_UNHOVER_DELAY = 300;
/**
* Listener interface to be notified of hover state changes.
*/
public static interface HoverListener {
/**
* Handles the event when the user hovers one of the partner elements.
*/
public void onHover();
}
/**
* Listener interface to be notified of hover state changes.
*/
public static interface UnhoverListener {
/**
* Handles the event when the user unhovers one of the partner elements, and
* does not hover another partner element within the fixed delay.
*/
public void onUnhover();
}
/**
* A helper class to store a partner element and it's event removers.
*/
private class PartnerHolder {
private final Element element;
private final EventRemover mouseOverRemover;
private final EventRemover mouseOutRemover;
PartnerHolder(final Element element) {
this.element = element;
mouseOverRemover = element.addEventListener(Event.MOUSEOVER, new EventListener() {
@Override
public void handleEvent(Event evt) {
if (relatedTargetOutsideElement((MouseEvent) evt)) {
hover();
}
}
}, false);
mouseOutRemover = element.addEventListener(Event.MOUSEOUT, new EventListener() {
@Override
public void handleEvent(Event evt) {
if (relatedTargetOutsideElement((MouseEvent) evt)) {
unhover();
}
}
}, false);
}
Element getElement() {
return element;
}
void teardown() {
mouseOverRemover.remove();
mouseOutRemover.remove();
}
/**
* Checks if the related target of the MouseEvent (the "from" element for a
* mouseover, the "to" element for a mouseout) is actually outside of the
* partner element. If the target element contains children, we will receive
* mouseover/mouseout events when the mouse moves over/out of the children,
* even if the mouse is still within the partner element. These
* intra-element events don't affect the hover state of the partner element,
* so we want to ignore them.
*/
private boolean relatedTargetOutsideElement(MouseEvent evt) {
EventTarget relatedTarget = evt.getRelatedTarget();
return relatedTarget == null || !element.contains((Node) relatedTarget);
}
}
private HoverListener hoverListener;
private UnhoverListener unhoverListener;
private boolean isHovering = false;
private int unhoverDelay = DEFAULT_UNHOVER_DELAY;
private Timer unhoverTimer;
private final JsonArray<PartnerHolder> partners = JsonCollections.createArray();
/**
* Adds a partner element to this controller. See class javadoc for
* an explanation of the interaction between partner elements.
*/
public void addPartner(Element element) {
if (!hasPartner(element)) {
partners.add(new PartnerHolder(element));
}
}
/**
* Removes a partner element from this controller.
*/
public void removePartner(Element element) {
for (int i = 0, n = partners.size(); i < n; ++i) {
PartnerHolder holder = partners.get(i);
if (holder.getElement() == element) {
holder.teardown();
partners.remove(i);
break;
}
}
}
private boolean hasPartner(Element element) {
for (int i = 0, n = partners.size(); i < n; ++i) {
PartnerHolder holder = partners.get(i);
if (holder.getElement() == element) {
return true;
}
}
return false;
}
/**
* Sets the listener that will receive events when any of the partner elements
* is hovered.
*/
public void setHoverListener(HoverListener listener) {
this.hoverListener = listener;
}
/**
* Sets the listener that will receive events when all of the partner elements
* are unhovered.
*/
public void setUnhoverListener(UnhoverListener listener) {
this.unhoverListener = listener;
}
/**
* Sets the delay between the last native mouseout event and when
* {@link UnhoverListener#onUnhover()} is called. If the user mouses out of
* one partner element and over another partner element within the unhover
* delay, the unhover event is not triggered.
*
* If the delay is zero, the unhover listener is called synchronously. If the
* delay is less than zero, the unhover listener is never called.
*
* @param delay the delay in milliseconds
*/
public void setUnhoverDelay(int delay) {
this.unhoverDelay = delay;
}
/**
* Cancels the unhover timer if one is pending. This will prevent an unhover
* listener from firing until the next time the user mouses out of a partner
* element.
*/
public void cancelUnhoverTimer() {
if (unhoverTimer != null) {
unhoverTimer.cancel();
unhoverTimer = null;
}
}
/**
* Flushes the unhover timer if one is pending. This will reset the hover
* controller to a state where it can fire a hover event the next time the
* element is hovered.
*/
public void flushUnhoverTimer() {
if (unhoverTimer != null) {
cancelUnhoverTimer();
unhoverNow();
}
}
/**
* Updates the state of the controller to indicate that the user is hovering
* over one of the partner elements.
*/
private void hover() {
cancelUnhoverTimer();
// Early exit if already hovering.
if (isHovering) {
return;
}
isHovering = true;
if (hoverListener != null) {
hoverListener.onHover();
}
}
/**
* Starts a timer that will update the controller to the unhover state if the
* user doesn't hover one of the partner elements within the specified unhover
* delay.
*/
private void unhover() {
// Early exit if already unhovering or if the delay is negative.
if (!isHovering || unhoverDelay < 0) {
return;
}
if (unhoverDelay == 0) {
unhoverNow();
} else if (unhoverTimer == null) {
// Wait a short time before unhovering so the user has a chance to move
// the mouse from one partner to another.
unhoverTimer = new Timer() {
@Override
public void run() {
unhoverNow();
}
};
unhoverTimer.schedule(unhoverDelay);
}
}
/**
* Updates the state of the controller to indicate that the user is no longer
* hovering any of the partner elements.
*/
private void unhoverNow() {
cancelUnhoverTimer();
isHovering = false;
if (unhoverListener != null) {
unhoverListener.onUnhover();
}
}
}