// 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.list; import com.google.collide.client.ui.ElementView; import com.google.collide.client.util.CssUtils; import com.google.collide.client.util.Elements; import com.google.collide.client.util.dom.DomUtils; import com.google.collide.client.util.logging.Log; import com.google.collide.json.shared.JsonArray; import com.google.collide.mvp.UiComponent; import com.google.collide.shared.util.JsonCollections; import com.google.gwt.resources.client.ClientBundle; import com.google.gwt.resources.client.CssResource; import elemental.events.Event; import elemental.events.EventListener; import elemental.html.Element; import elemental.js.html.JsElement; // TODO: Where possible, port more lists that we have hand-rolled in // other parts of the UI to use this widget. /** * A simple list widget for displaying flat collections of things. */ // TODO: When we hit a place where a componenet wants to ditch all of // the default simple list styles, figure out a way to make that easy. public class SimpleList<M> extends UiComponent<SimpleList.View> { /** * Create using the default CSS. */ public static <M> SimpleList<M> create(View view, Resources res, ListItemRenderer<M> itemRenderer, ListEventDelegate<M> eventDelegate) { return new SimpleList<M>(view, view, view, res.defaultSimpleListCss(), itemRenderer, eventDelegate); } /** * Create with custom CSS. */ public static <M> SimpleList<M> create(View view, Css css, ListItemRenderer<M> itemRenderer, ListEventDelegate<M> eventDelegate) { return new SimpleList<M>(view, view, view, css, itemRenderer, eventDelegate); } /** * Create and configure control instance. * * <p> Use this method when either only part of control is scrollable * ({@code view != container}) or elements are stored in table * ({@code container != itemHolder}) or both. * * @param view element that receives control decorations (shadow, etc.) * @param container element whose content is scrolled * @param itemHolder element to add items to */ public static <M> SimpleList<M> create(View view, Element container, Element itemHolder, Css css, ListItemRenderer<M> itemRenderer, ListEventDelegate<M> eventDelegate) { return new SimpleList<M>(view, container, itemHolder, css, itemRenderer, eventDelegate); } /** * Called each time we render an item in the list. Provides an opportunity for * implementors/clients to customize the DOM structure of each list item. */ public abstract static class ListItemRenderer<M> { public abstract void render(Element listItemBase, M itemData); /** * A factory method for the outermost element used by a list item. * * The default implementation returns a div element. */ public Element createElement() { return Elements.createDivElement(); } } /** * Receives events fired on items in the list. */ public interface ListEventDelegate<M> { void onListItemClicked(Element listItemBase, M itemData); } /** * A {@link ListEventDelegate} which performs no action. */ public static class NoOpListEventDelegate<M> implements ListEventDelegate<M> { @Override public void onListItemClicked(Element listItemBase, M itemData) { } } /** * Item style selectors for a simple list item. */ public interface Css extends CssResource { int menuListBorderPx(); String listItem(); String listBase(); String listContainer(); } public interface Resources extends ClientBundle { @Source({"SimpleList.css", "com/google/collide/client/common/constants.css"}) Css defaultSimpleListCss(); @Source({"Sidebar.css"}) SidebarListItemRenderer.Css sidebarListCss(); } /** * Overlay type representing the base element of the SimpleList. */ public static class View extends ElementView<Void> { protected View() { } } /** * A javascript overlay object which ties a list item's DOM element to its * associated data. * */ final static class ListItem<M> extends JsElement { /** * Creates a new ListItem overlay object by creating a div element, * assigning it the listItem css class, and associating it to its data. */ public static <M> ListItem<M> create(ListItemRenderer<M> factory, Css css, M data) { Element element = factory.createElement(); element.addClassName(css.listItem()); ListItem<M> item = ListItem.cast(element); item.setData(data); return item; } /** * Casts an element to its ListItem representation. This is an unchecked * cast so we extract it into this static factory method so we don't have to * suppress warnings all over the place. */ @SuppressWarnings("unchecked") public static <M> ListItem<M> cast(Element element) { return (ListItem<M>) element; } protected ListItem() { // Unused constructor } public final native M getData() /*-{ return this.__data; }-*/; public final native void setData(M data) /*-{ this.__data = data; }-*/; /** * Reuses this elements container by clearing it's contents and updating its * data. This allows it to be sent back to the renderer. * * @param className The css class to set to the container. All other css * classes are cleared. */ public final native void reuseContainerElement(M data, String className) /*-{ this.className = className; this.innerHTML = ""; this.__data = data; }-*/; } /** * A model which maintains the internal state of the list including selection * and DOM elements. * */ public class Model<M> implements HasSelection<M> { private static final int NO_SELECTION = -1; /** * Defines the attribute used to indicate selection. */ private static final String SELECTED_ATTRIBUTE = "SELECTED"; private final ListEventDelegate<M> delegate; private final JsonArray<ListItem<M>> listItems = JsonCollections.createArray(); private int selectedIndex; /** * Creates a new model for use by SimpleList. The provided delegate should * not be null. */ public Model(ListEventDelegate<M> delegate) { this.delegate = delegate; // set the initially selected item selectedIndex = 0; } @Override public int getSelectedIndex() { return selectedIndex; } @Override public M getSelectedItem() { ListItem<M> selectedListItem = getSelectedListItem(); return selectedListItem != null ? selectedListItem.getData() : null; } /** * Returns the currently selected list element or null. */ private ListItem<M> getSelectedListItem() { if (selectedIndex >= 0 && selectedIndex < listItems.size()) { return listItems.get(selectedIndex); } return null; } @Override public boolean selectNext() { return setSelectedItem(selectedIndex + 1); } @Override public boolean selectPrevious() { return setSelectedItem(selectedIndex - 1); } @Override public boolean selectNextPage() { return setSelectedItem(Math.min(selectedIndex + getPageSize(), size() - 1)); } @Override public boolean selectPreviousPage() { return setSelectedItem(Math.max(0, selectedIndex - getPageSize())); } private int getPageSize() { int indexAboveViewport = findIndexOfFirstNotInViewport(selectedIndex, false); int indexBelowViewport = findIndexOfFirstNotInViewport(selectedIndex, true); // A minimum size of 1 return Math.max(1, indexBelowViewport - indexAboveViewport - 1); } /** * Returns the index of the first item that is not fully in the viewport. If * all items are, it will return the last item in the given direction. */ private int findIndexOfFirstNotInViewport(int beginIndex, boolean forward) { final int deltaIndex = forward ? 1 : -1; int i = beginIndex; for (; i >= 0 && i < size(); i += deltaIndex) { if (!DomUtils.isFullyInScrollViewport(container, listItems.get(i))) { return i; } } return i + -deltaIndex; } @Override public void handleClick() { ListItem<M> item = getSelectedListItem(); if (item != null) { delegate.onListItemClicked(item, item.getData()); } } @Override public void clearSelection() { maybeRemoveSelectionFromElement(); selectedIndex = NO_SELECTION; } @Override public int size() { return listItems.size(); } @Override public boolean setSelectedItem(int index) { if (index >= 0 && index < listItems.size()) { maybeRemoveSelectionFromElement(); selectedIndex = index; getSelectedListItem().setAttribute(SELECTED_ATTRIBUTE, SELECTED_ATTRIBUTE); ensureSelectedIsVisible(); return true; } return false; } @Override public boolean setSelectedItem(M item) { int index = -1; for (int i = 0; i < listItems.size(); i++) { if (listItems.get(i).getData().equals(item)) { index = i; break; } } return setSelectedItem(index); } private void ensureSelectedIsVisible() { DomUtils.ensureScrolledTo(container, model.getSelectedListItem()); } /** * Removes selection from the currently selected element if it exists. */ private void maybeRemoveSelectionFromElement() { ListItem<M> element = getSelectedListItem(); if (element != null) { element.removeAttribute(SELECTED_ATTRIBUTE); } } } private final ListEventDelegate<M> eventDelegate; private final ListItemRenderer<M> itemRenderer; private final Css css; private final Model<M> model; private final Element container; private final Element itemHolder; private SimpleList(View view, Element container, Element itemHolder, Css css, ListItemRenderer<M> itemRenderer, ListEventDelegate<M> eventDelegate) { super(view); this.css = css; this.model = new Model<M>(eventDelegate); this.itemRenderer = itemRenderer; this.eventDelegate = eventDelegate; this.itemHolder = itemHolder; this.container = container; view.addClassName(css.listBase()); container.addClassName(css.listContainer()); attachEventHandlers(); } /** * Returns the current number of items in the simple list. */ public int size() { return model.listItems.size(); } /** * Refreshes list of items. * * <p>This method tries to keep selection. */ public void render(JsonArray<M> items) { M selectedItem = model.getSelectedItem(); model.clearSelection(); itemHolder.setInnerHTML(""); model.listItems.clear(); for (int i = 0; i < items.size(); i++) { ListItem<M> elem = ListItem.create(itemRenderer, css, items.get(i)); CssUtils.setUserSelect(elem, false); model.listItems.add(elem); itemRenderer.render(elem, elem.getData()); itemHolder.appendChild(elem); } model.setSelectedItem(selectedItem); } public HasSelection<M> getSelectionModel() { return model; } /** * @return true if the list or any one of its element's currently have focus. */ public boolean hasFocus() { return DomUtils.isElementOrChildFocused(container); } private void attachEventHandlers() { getView().addEventListener(Event.CLICK, new EventListener() { @Override public void handleEvent(Event evt) { Element listItemElem = CssUtils.getAncestorOrSelfWithClassName( (Element) evt.getTarget(), css.listItem()); if (listItemElem == null) { Log.warn(SimpleList.class, "Unable to find an ancestor that was a list item for a click on: ", evt.getTarget()); return; } ListItem<M> listItem = ListItem.cast(listItemElem); eventDelegate.onListItemClicked(listItem, listItem.getData()); } }, false); } public M get(int i) { return model.listItems.get(i).getData(); } }