// 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.search.awesomebox; import com.google.collide.client.AppContext; import com.google.collide.client.common.BaseResources; import com.google.collide.client.search.awesomebox.AwesomeBoxModel.ContextChangeListener; import com.google.collide.client.search.awesomebox.host.AbstractAwesomeBoxComponent; import com.google.collide.client.search.awesomebox.host.ComponentHost; import com.google.collide.client.ui.tooltip.Tooltip; import com.google.collide.client.util.CssUtils; import com.google.collide.client.util.Elements; import com.google.collide.client.util.UserActivityManager; import com.google.collide.json.shared.JsonArray; import com.google.collide.mvp.CompositeView; import com.google.collide.mvp.HasView; import com.google.collide.shared.util.StringUtils; import com.google.gwt.core.client.GWT; import com.google.gwt.core.client.Scheduler; import com.google.gwt.core.client.Scheduler.ScheduledCommand; import com.google.gwt.dom.client.DivElement; import com.google.gwt.dom.client.Element; import com.google.gwt.dom.client.InputElement; import com.google.gwt.resources.client.CssResource; import com.google.gwt.uibinder.client.UiBinder; import com.google.gwt.uibinder.client.UiField; import com.google.gwt.uibinder.client.UiTemplate; import com.google.gwt.user.client.Timer; import elemental.dom.Node; import elemental.events.Event; import elemental.events.EventListener; import elemental.events.KeyboardEvent; import elemental.events.KeyboardEvent.KeyCode; import elemental.events.MouseEvent; import elemental.html.HTMLCollection; /** * The main controller and view for the awesome box * */ /* * The autohide component was not used since this component does not strictly * utilize a full auto-hide type functionality. The input box is still part of * the control (for styling and ui reasons) but does not hide. */ // TODO: In the future lets add some sort of query ranking/processor. public class AwesomeBox extends AbstractAwesomeBoxComponent implements HasView<AwesomeBox.View> { /** * Creates a new AwesomeBox component and returns it. Though the class * instance will be unique, the underlying model will remain consistent among * all AwesomeBox's. This allows the AwesomeBox to be added at different * places but rely on only one underlying set of data for a consistent * experience. */ public static AwesomeBox create(AppContext context) { return new AwesomeBox(new AwesomeBox.View(context.getResources()), context.getAwesomeBoxModel(), context.getUserActivityManager()); } public interface Css extends CssResource { /* Generic Awesome Box and Container Styles */ String awesomeContainer(); String dropdownContainer(); String closeButton(); String awesomeBox(); /* Generic Section Styles */ String section(); String selected(); String sectionItem(); String shortcut(); } public interface SectionCss extends CssResource { /* Goto File Actions */ String fileItem(); String folder(); /* Goto Branch Actions */ String branchIcon(); /* Find Actions */ String searchIcon(); } public interface Resources extends BaseResources.Resources, Tooltip.Resources { @Source({"AwesomeBox.css", "com/google/collide/client/common/constants.css"}) public Css awesomeBoxCss(); @Source("AwesomeBoxSection.css") public SectionCss awesomeBoxSectionCss(); } /** * Defines an AwesomeBox section which is hidden until the AwesomeBox is * focused. * */ public interface AwesomeBoxSection { /** * Actions that can be taken when a section item is selected. */ public enum ActionResult { /** * An action was performed and the AwesomeBox should be closed. */ CLOSE, /** * An item was selected and the section should receive selection. */ SELECT_ITEM, /** * Do nothing. */ DO_NOTHING } /** * Called when the section has been added to a context. Any context related * setup should be performed here. */ public void onAddedToContext(AwesomeBoxContext context); /** * Called when the query in the AwesomeBox is modified and the section may * need to be filtered. * * If the section currently has a selection it should be removed upon query * change. * * @return true if the section has results and should be visible. */ public boolean onQueryChanged(String query); /** * Called when the global context has been changed to a context containing * this section and any previous state should most likely be removed. */ public void onContextChanged(AwesomeBoxContext context); /** * Called when the AwesomeBox is focused and in a context that this section * is a member of. The section should prepare itself for the AwesomeBox to * be empty. * * @return true if section should be immediately visible in the dropdown. */ public boolean onShowing(AwesomeBox awesomeBox); /** * Called when the AwesomeBox panel is hidden due to loss of focus or * external click. */ public void onHiding(AwesomeBox awesomeBox); /** * Called to move the selection down or up. The contract for this method * specifies that the section will return false if it is at a boundary and * unable to move the selection or does not accept selection. When unable to * move selection it should not assume it has lost selection until * onClearSelection is called. * * @param moveDown true if the selection is moving down, false if up. If the * section currently has no selection it should select it's first * item when moveDown is true and it's last item if moveDown is * false. * * @return true if the selection was moved successfully */ /* * This method off-loads selection onto the sections with little expectation * set forth by the AwesomeBox. This allows for sections which are * non-standard and can be selected in different ways (or not at all). */ public boolean onMoveSelection(boolean moveDown); /** * The selection has been reset. If the section has any item selected it * should clear the selection. */ public void onClearSelection(); /** * The enter key has been pressed and the target is the current selection in * this section. The necessary action should be performed. * * @return Appropriate action to take after action has been performed */ public ActionResult onActionRequested(); /** * Called when the tab completion key is pressed in the AwesomeBox and the * section currently has selection. * * @return A string representing the item currently selected. Null or empty * will cancel the completion. */ public String onCompleteSelection(); /** * Called when a click event is received where the target is a child of this * section. Typically a section should call mouseEvent.preventDefault() to * prevent native selection and focus from being transferred. * * @param mouseEvent The location of the click. * * @return The appropriate action for the AwesomeBox to take. */ public ActionResult onSectionClicked(MouseEvent mouseEvent); /** * Returns the div element which wraps this section's contents. */ public elemental.html.DivElement getElement(); } /** * Callback used when iterating through sections. */ public interface SectionIterationCallback { public boolean onIteration(AwesomeBoxSection section); } private static final String NO_QUERY = ""; private final AwesomeBoxModel model; private final UserActivityManager userActivityManager; private final View view; /** A hacky means to ensure our keyup handler ignores enter after show */ private boolean ignoreEnterKeyUp = false; /** * Tracks the last query we have dispatched to ensure that we don't duplicate * query changed events. */ private String lastDispatchedQuery = NO_QUERY; protected AwesomeBox(View view, AwesomeBoxModel model, UserActivityManager userActivityManager) { super(HideMode.AUTOHIDE, HiddenBehavior.STAY_ACTIVE, "Type to find files and features"); this.view = view; this.model = model; this.userActivityManager = userActivityManager; view.setDelegate(new ViewEventsImpl()); model.getContextChangeListener().add(new ContextChangeListener() { @Override public void onContextChanged(boolean contextAlreadyActive) { if (contextAlreadyActive) { selectQuery(); } else { refreshView(); } } }); } /** * Returns the view for this component. */ @Override public View getView() { return view; } /** * Refreshes the view by reloading all the section DOM and clearing selection. */ private void refreshView() { getView().clearSections(); getView().awesomeBoxInput.setAttribute( "placeholder", getModel().getContext().getPlaceholderText()); getView().setInputEmptyStyle(getModel().getContext().getAlwaysShowCloseButton()); JsonArray<AwesomeBoxSection> sections = getModel().getCurrentSections(); for (int i = 0; i < sections.size(); i++) { AwesomeBoxSection section = sections.get(i); if (isActive()) { boolean showInitial = section.onShowing(this); CssUtils.setDisplayVisibility2(section.getElement(), StringUtils.isNullOrEmpty(getQuery()) ? showInitial : section.onQueryChanged( getQuery())); } getView().getElement().appendChild(section.getElement()); } // If the view is expanded, try to get back to a default state of some kind. if (isActive()) { getModel().selectFirstItem(); getView().awesomeBoxInput.focus(); } } @Override public elemental.html.Element getElement() { return getView().getElement(); } @Override public String getTooltipText() { return "Press Alt+Enter to quickly access the AwesomeBox"; } AwesomeBoxModel getModel() { return model; } /** * Retrieves the current query of the AwesomeBox. */ public String getQuery() { return getView().awesomeBoxInput.getValue(); } /** * Sets the query of the AwesomeBox, this will not trigger a query changed * event. */ public void setQuery(String query) { getView().awesomeBoxInput.setValue(query); getView().setInputEmptyStyle(getModel().getContext().getAlwaysShowCloseButton()); } /** * Selects whatever text is in the Awesomebox input. */ public void selectQuery() { getView().awesomeBoxInput.select(); } @Override public void onShow(ComponentHost host, ShowReason reason) { super.onShow(host, reason); // We assume the alt+enter shortcut focused us (and its a keydown listener). ignoreEnterKeyUp = reason == ShowReason.OTHER; JsonArray<AwesomeBoxSection> sections = getModel().getCurrentSections(); for (int i = 0; i < sections.size(); i++) { AwesomeBoxSection section = sections.get(i); CssUtils.setDisplayVisibility2(section.getElement(), section.onShowing(AwesomeBox.this)); getView().getElement().appendChild(section.getElement()); } getModel().selectFirstItem(); // Show the panel getView().setInputEmptyStyle(getModel().getContext().getAlwaysShowCloseButton()); } @Override public void onHide() { super.onHide(); getModel().clearSelection(); JsonArray<AwesomeBoxSection> sections = getModel().getCurrentSections(); for (int i = 0; i < sections.size(); i++) { AwesomeBoxSection section = sections.get(i); section.onHiding(AwesomeBox.this); CssUtils.setDisplayVisibility2(section.getElement(), false); } lastDispatchedQuery = NO_QUERY; getView().awesomeBoxInput.setValue(""); getView().setInputEmptyStyle(false); } /** * Focuses the AwesomeBox view. */ @Override public void focus() { getView().awesomeBoxInput.focus(); } private interface ViewEvents { public void onCloseClicked(); /** * The AwesomeBox input has lost focus. */ public void onBlur(); /** * Called when the AwesomeBox panel is clicked. */ public void onClick(MouseEvent mouseEvent); /** * Fired when a key down event occurs on the AwesomeBox input. */ public void onInputKeyDown(KeyboardEvent keyEvent); public void onKeyUp(KeyboardEvent keyEvent); } private class ViewEventsImpl implements ViewEvents { @Override public void onBlur() { hide(); } @Override public void onClick(MouseEvent mouseEvent) { mouseEvent.stopPropagation(); boolean isInInput = getView().mainInput.isOrHasChild((Element) mouseEvent.getTarget()); boolean isInDropDown = getView().getElement().contains((Node) mouseEvent.getTarget()); if (mouseEvent.getButton() == MouseEvent.Button.PRIMARY && isInDropDown) { sectionClicked(mouseEvent); } else if (!isInInput) { mouseEvent.preventDefault(); } } private void sectionClicked(MouseEvent mouseEvent) { JsonArray<AwesomeBoxSection> sections = getModel().getCurrentSections(); for (int i = 0; i < sections.size(); i++) { AwesomeBoxSection section = sections.get(i); if (section.getElement().contains((Node) mouseEvent.getTarget())) { switch (section.onSectionClicked(mouseEvent)) { case CLOSE: hide(); break; case SELECT_ITEM: /** * We assume the section has internally handled selection, set * selection only clears selection if it was on another section * previously. */ getModel().setSelection(section); break; } return; } } } @Override public void onKeyUp(KeyboardEvent keyEvent) { if (keyEvent.getKeyCode() == KeyCode.ENTER) { if (ignoreEnterKeyUp) { ignoreEnterKeyUp = false; return; } AwesomeBoxSection section = getModel().getSelection(AwesomeBoxModel.SelectMode.TRY_AUTOSELECT_FIRST_ITEM); if (section != null && section.onActionRequested() == AwesomeBoxSection.ActionResult.CLOSE) { hide(); } } else if (!lastDispatchedQuery.equals(getQuery())) { lastDispatchedQuery = getQuery(); // TODO: allow context to choose rather query change is batched. deferQueryChangeTimer.cancel(); deferQueryChangeTimer.schedule(30); } } /** * Timer which defers query change until 50ms after the user has stopped * typing. This prevents spamming the event if the user is typing in a large * amount of text quickly. */ private final Timer deferQueryChangeTimer = new Timer() { @Override public void run() { dispatchQueryChangeEvent(); } }; private void dispatchQueryChangeEvent() { // force selection to be cleared before a query change getModel().clearSelection(); JsonArray<AwesomeBoxSection> sections = getModel().getCurrentSections(); for (int i = 0; i < sections.size(); i++) { AwesomeBoxSection section = sections.get(i); CssUtils.setDisplayVisibility2( section.getElement(), section.onQueryChanged(getView().awesomeBoxInput.getValue())); } // Select the first item in our list getModel().selectFirstItem(); } private void changeSelection(final boolean moveDown) { AwesomeBoxSection section = getModel().getSelection(AwesomeBoxModel.SelectMode.DEFAULT); if (section == null) { // if null then we should reset selection to the top or the bottom getModel().selectFirstItem(); } else if (!section.onMoveSelection(moveDown)) { getModel().iterateFrom(section, moveDown, new SectionIterationCallback() { @Override public boolean onIteration(AwesomeBoxSection curSection) { return !getModel().trySetSelection(curSection, moveDown); } }); // if nothing is selected on iteration then the selection doesn't change } } @Override public void onInputKeyDown(KeyboardEvent keyEvent) { if (keyEvent.getKeyCode() == KeyCode.UP) { changeSelection(false); } else if (keyEvent.getKeyCode() == KeyCode.DOWN) { changeSelection(true); } else if (keyEvent.getKeyCode() == KeyCode.TAB) { handleTabComplete(); if (getModel().getContext().getPreventTab()) { keyEvent.preventDefault(); } } dispatchEmptyAwesomeBoxCheck(); userActivityManager.markUserActive(); // For a few keys the default is always prevented. if (keyEvent.getKeyCode() == KeyCode.UP || keyEvent.getKeyCode() == KeyCode.DOWN) { keyEvent.preventDefault(); } else { getModel().getContext().getShortcutManager().onKeyDown(keyEvent); } } /** * Handles tab based query completion. */ private void handleTabComplete() { AwesomeBoxSection section = getModel().getSelection(AwesomeBoxModel.SelectMode.DEFAULT); if (section != null) { String completion = section.onCompleteSelection(); if (completion != null) { // TODO: Potentially highlight completed part of the query getView().awesomeBoxInput.setValue(completion); } } } private void dispatchEmptyAwesomeBoxCheck() { Scheduler.get().scheduleDeferred(new ScheduledCommand() { @Override public void execute() { getView().setInputEmptyStyle(getModel().getContext().getAlwaysShowCloseButton()); } }); } @Override public void onCloseClicked() { hide(); } } public static class View extends CompositeView<ViewEvents> { @UiTemplate("AwesomeBox.ui.xml") interface AwesomeBoxUiBinder extends UiBinder<Element, View> { } private static AwesomeBoxUiBinder uiBinder = GWT.create(AwesomeBoxUiBinder.class); @UiField(provided = true) final Resources res; @UiField InputElement awesomeBoxInput; @UiField DivElement closeButton; @UiField DivElement mainInput; public View(Resources res) { this.res = res; setElement(Elements.asJsElement(uiBinder.createAndBindUi(this))); attachHandlers(); } /** * Attaches several handlers to the awesome box input and the container. */ private void attachHandlers() { Elements.asJsElement(awesomeBoxInput).setOnBlur(new EventListener() { @Override public void handleEvent(Event event) { // blur removes the focus then we hide the actual panel if (getDelegate() != null) { getDelegate().onBlur(); } } }); Elements.asJsElement(awesomeBoxInput).setOnKeyDown(new EventListener() { @Override public void handleEvent(Event event) { KeyboardEvent keyEvent = (KeyboardEvent) event; if (getDelegate() != null) { getDelegate().onInputKeyDown(keyEvent); } } }); Elements.asJsElement(closeButton).setOnClick(new EventListener() { @Override public void handleEvent(Event arg0) { getDelegate().onCloseClicked(); } }); getElement().setOnKeyUp(new EventListener() { @Override public void handleEvent(Event event) { KeyboardEvent keyEvent = (KeyboardEvent) event; if (getDelegate() != null) { getDelegate().onKeyUp(keyEvent); } } }); getElement().setOnMouseDown(new EventListener() { @Override public void handleEvent(Event event) { MouseEvent mouseEvent = (MouseEvent) event; if (getDelegate() != null) { getDelegate().onClick(mouseEvent); } } }); } /** * Removes all sections DOM from the AwesomeBox. */ private void clearSections() { HTMLCollection elements = getElement().getChildren(); for (int l = elements.getLength() - 1; l >= 0; l--) { if (elements.item(l) != mainInput) { elements.item(l).removeFromParent(); } } } /** * Sets the empty or non-empty styles of the AwesomeBox. */ private void setInputEmptyStyle(boolean alwaysShowClose) { boolean isEmpty = StringUtils.isNullOrEmpty(awesomeBoxInput.getValue()); // Intentional use of setDisplayVisibility CssUtils.setDisplayVisibility(Elements.asJsElement(closeButton), !isEmpty || alwaysShowClose); } } }