// 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.dropdown;
import com.google.collide.client.ui.dropdown.DropdownWidgets.Resources;
import com.google.collide.client.ui.list.KeyboardSelectionController;
import com.google.collide.client.ui.list.SimpleList;
import com.google.collide.client.ui.list.SimpleList.ListItemRenderer;
import com.google.collide.client.ui.menu.AutoHideController;
import com.google.collide.client.ui.menu.PositionController;
import com.google.collide.client.ui.menu.AutoHideComponent.AutoHideHandler;
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.ui.tooltip.Tooltip;
import com.google.collide.client.util.Elements;
import com.google.collide.json.shared.JsonArray;
import elemental.css.CSSStyleDeclaration;
import elemental.events.Event;
import elemental.events.EventListener;
import elemental.html.Element;
/**
* A controller that can add a dropdown to any element.
*
*/
public class DropdownController<M> {
/**
* Simplifies construction of a dropdown controller by setting reasonable
* defaults for most options.
*
*/
public static class Builder<M> {
/** Indicates the width of the dropdown's anchor should be used. */
public final static int WIDTH_OF_ANCHOR = -1;
private Element input;
private Tooltip anchorTooltip;
private boolean autoFocus = true;
private boolean enableKeyboardSelection = false;
private int maxHeight;
private int maxWidth;
private final Resources res;
private final Listener<M> listener;
private final ListItemRenderer<M> renderer;
private final Positioner positioner;
private final Element trigger;
/**
* @see DropdownPositionerBuilder
*/
public Builder(Positioner positioner, Element trigger, DropdownWidgets.Resources res,
Listener<M> listener,
SimpleList.ListItemRenderer<M> renderer) {
this.positioner = positioner;
this.res = res;
this.listener = listener;
this.renderer = renderer;
this.trigger = trigger;
}
public Builder<M> setInputTargetElement(Element inputTarget) {
this.input = inputTarget;
return this;
}
public Builder<M> setAnchorTooltip(Tooltip tooltip) {
this.anchorTooltip = tooltip;
return this;
}
public Builder<M> setShouldAutoFocusOnOpen(boolean autoFocus) {
this.autoFocus = autoFocus;
return this;
}
public Builder<M> setKeyboardSelectionEnabled(boolean enabled) {
this.enableKeyboardSelection = enabled;
return this;
}
public Builder<M> setMaxHeight(int maxHeight) {
this.maxHeight = maxHeight;
return this;
}
public Builder<M> setMaxWidth(int maxWidth) {
this.maxWidth = maxWidth;
return this;
}
public DropdownController<M> build() {
return new DropdownController<M>(res,
listener,
renderer,
positioner,
trigger,
input,
anchorTooltip,
autoFocus,
enableKeyboardSelection,
maxHeight,
maxWidth);
}
}
/**
* A {@link PositionerBuilder} which starts with its {@link VerticalAlign} property set to
* {@link VerticalAlign#BOTTOM} for convenience when building dropdowns.
*/
public static class DropdownPositionerBuilder extends PositionerBuilder {
public DropdownPositionerBuilder() {
setVerticalAlign(VerticalAlign.BOTTOM);
}
}
/**
* Base listener that provides default implementations (i.e. no-op) for the
* Listener interface. This allows subclasses to only override the methods it
* is interested in.
*/
public static class BaseListener<M> implements Listener<M> {
@Override
public void onItemClicked(M item) {
}
@Override
public void onHide() {
}
@Override
public void onShow() {
}
}
public interface Listener<M> {
void onItemClicked(M item);
void onHide();
void onShow();
}
private static final int DROPDOWN_ZINDEX = 20005;
private final DropdownWidgets.Resources res;
private final Listener<M> listener;
private final ListItemRenderer<M> renderer;
private JsonArray<M> itemsForLazyCreation;
private SimpleList<M> list;
private AutoHideController listAutoHider;
// if null, keyboard selection is disabled
private KeyboardSelectionController keyboardSelectionController = null;
private final Element inputTarget;
private final Element trigger;
private final Tooltip anchorTooltip;
private final boolean shouldAutoFocusOnOpen;
private final boolean enableKeyboardSelection;
private final int maxHeight;
private final int maxWidth;
private final Positioner positioner;
private PositionController positionController;
private boolean isDisabled;
/**
* Creates a DropdownController which appears relative to an anchor.
*
* @param res the {@link Resources}
* @param listener a {@link Listener} for dropdown events.
* @param renderer the {@link ListItemRenderer} for rendering each list item
* @param positioner the {@link Positioner} used by {@link PositionController} to position the
* dropdown.
* @param trigger the item (usually a button) for which to show the menu (optional)
* @param inputTarget the element used to record input from for keyboard navigation
* @param shouldAutoFocusOnOpen autofocuses the list on open for keyboard navigation
*/
private DropdownController(DropdownWidgets.Resources res,
Listener<M> listener,
SimpleList.ListItemRenderer<M> renderer,
Positioner positioner,
Element trigger,
Element inputTarget,
Tooltip anchorTooltip,
boolean shouldAutoFocusOnOpen,
boolean enableKeyboardSelection,
int maxHeight,
int maxWidth) {
this.res = res;
this.listener = listener;
this.renderer = renderer;
this.positioner = positioner;
this.inputTarget = inputTarget;
this.trigger = trigger;
this.anchorTooltip = anchorTooltip;
this.shouldAutoFocusOnOpen = shouldAutoFocusOnOpen;
this.enableKeyboardSelection = enableKeyboardSelection;
this.maxHeight = maxHeight;
this.maxWidth = maxWidth;
if (trigger != null) {
attachEventHandlers(trigger);
}
}
private void attachEventHandlers(Element trigger) {
trigger.addEventListener(Event.CLICK, new EventListener() {
@Override
public void handleEvent(Event evt) {
if (listAutoHider == null || !listAutoHider.isShowing()) {
show();
} else {
hide();
}
}
}, false);
}
public void setItems(JsonArray<M> items) {
if (list != null) {
list.render(items);
} else {
itemsForLazyCreation = items;
}
}
/**
* Sets the tooltip associated with the element so it can be disabled while
* the dropdown is showing.
*/
public void setElementTooltip(Tooltip tooltip) {
listAutoHider.setTooltip(tooltip);
}
/**
* Show the controller relative to the anchor.
*/
public void show() {
if (!isDisabled && preShowCheck()) {
updatePositionAndSize();
postShow();
}
}
public boolean isShowing() {
return listAutoHider != null && listAutoHider.isShowing();
}
/**
* Hides the dropdown if it is showing. Shows it if it is hidden.
*/
public void toggle() {
if (isShowing()) {
hide();
} else {
show();
}
}
/**
* Will show the controller at the given position (ignoring the anchor).
*/
public void showAtPosition(int x, int y) {
if (!isDisabled && preShowCheck()) {
updatePositionAndSizeAtCoordinates(x, y);
postShow();
}
}
/**
* Do this before showing the dropdown
* @return true if we should show, false otherwise
*/
private boolean preShowCheck() {
ensureDropdownCreated();
return list.size() > 0;
}
private void postShow() {
if (keyboardSelectionController != null) {
keyboardSelectionController.setHandlerEnabled(true);
}
listAutoHider.show();
if (shouldAutoFocusOnOpen) {
list.getView().focus();
}
}
public void hide() {
if (keyboardSelectionController != null) {
keyboardSelectionController.setHandlerEnabled(false);
}
if (listAutoHider != null) {
// in case we can't hide before we've created the dropdown
listAutoHider.hide();
}
}
/**
* Returns the simple list container element
*/
public Element getElement() {
// if they want the element we must ensure it has been created.
ensureDropdownCreated();
return list.getView();
}
public SimpleList<M> getList() {
ensureDropdownCreated();
return list;
}
/**
* Enables or disables the dropdown. Does not affect the current showing state.
*/
public void setDisabled(boolean isDisabled) {
this.isDisabled = isDisabled;
}
private void ensureDropdownCreated() {
if (list == null) {
createDropdown();
}
}
/**
* Prevents default on all mouse clicks the dropdown receives. There is no way
* to remove the handler once it is set.
*/
public void preventDefaultOnMouseDown() {
// Prevent the dropdown from taking focus on click
getElement().addEventListener(Event.MOUSEDOWN, new EventListener() {
@Override
public void handleEvent(Event evt) {
evt.preventDefault();
}
}, false);
}
private void createDropdown() {
SimpleList.View listView = (SimpleList.View) Elements.createDivElement();
listView.setTabIndex(100);
listView.getStyle().setZIndex(DROPDOWN_ZINDEX);
SimpleList.ListEventDelegate<M> listEventDelegate = new SimpleList.ListEventDelegate<M>() {
@Override
public void onListItemClicked(Element listItemBase, M itemData) {
handleItemClicked(itemData);
}
};
list = SimpleList.create(listView, res, renderer, listEventDelegate);
if (itemsForLazyCreation != null) {
list.render(itemsForLazyCreation);
itemsForLazyCreation = null;
}
listAutoHider = AutoHideController.create(list.getView());
listAutoHider.setTooltip(anchorTooltip);
// Don't actually autohide (we use it for outside click dismissal)
listAutoHider.setDelay(-1);
if (trigger != null) {
listAutoHider.addPartnerClickTargets(trigger);
}
listAutoHider.setAutoHideHandler(new AutoHideHandler() {
@Override
public void onShow() {
listener.onShow();
}
@Override
public void onHide() {
listAutoHider.getView().getElement().removeFromParent();
listener.onHide();
}
});
if (enableKeyboardSelection) {
keyboardSelectionController = new KeyboardSelectionController(
inputTarget == null ? listView : inputTarget, list.getSelectionModel());
}
positionController = new PositionController(positioner, listView);
}
private void updatePositionAndSize() {
Element dropdownElement = listAutoHider.getView().getElement();
updateWidthAndHeight(dropdownElement);
positionController.updateElementPosition(0, 0);
}
private void updatePositionAndSizeAtCoordinates(int x, int y) {
Element dropdownElement = listAutoHider.getView().getElement();
// ensure we're attached to the DOM
Elements.getBody().appendChild(dropdownElement);
updateWidthAndHeight(dropdownElement);
positionController.updateElementPosition(x, y);
}
private void updateWidthAndHeight(Element dropdownElement) {
/*
* The 'outline' is drawn to the left of and above where the absolute top
* and left are, so account for them in the top and right.
*/
CSSStyleDeclaration style = dropdownElement.getStyle();
/*
* This width will either be 0 if we're being positioned by the mouse or the width of our
* anchor.
*/
int widthOfAnchorOrZero = positioner.getMinimumWidth();
// set the minimum width
int dropdownViewMinWidth =
widthOfAnchorOrZero - (2 * res.defaultSimpleListCss().menuListBorderPx());
style.setProperty("min-width", dropdownViewMinWidth + "px");
// sets the maximum width
boolean useWidthOfAnchor = maxWidth == Builder.WIDTH_OF_ANCHOR && widthOfAnchorOrZero != 0;
if (maxWidth != 0 && useWidthOfAnchor) {
int curMaxWidth = useWidthOfAnchor ? widthOfAnchorOrZero : maxWidth;
style.setProperty("max-width", curMaxWidth + "px");
}
if (maxHeight != 0) {
style.setProperty("max-height", maxHeight + "px");
}
}
private void handleItemClicked(M item) {
hide();
listener.onItemClicked(item);
}
}