/*
* JBoss, Home of Professional Open Source.
* Copyright 2010, Red Hat, Inc., and individual contributors
* as indicated by the @author tags. See the copyright.txt file in the
* distribution for a full listing of individual contributors.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.jboss.gwt.elemento.core;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Stack;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import com.google.common.base.Joiner;
import com.google.common.collect.Iterables;
import com.google.gwt.safehtml.shared.SafeHtml;
import com.google.gwt.user.client.ui.IsWidget;
import com.google.gwt.user.client.ui.Widget;
import elemental2.dom.Document;
import elemental2.dom.DomGlobal;
import elemental2.dom.Element;
import elemental2.dom.HTMLElement;
import elemental2.dom.HTMLInputElement;
import elemental2.dom.Node;
import elemental2.dom.NodeList;
import jsinterop.base.Js;
import org.jetbrains.annotations.NonNls;
import static java.util.Arrays.asList;
/**
* Helper methods for working with {@link elemental2.dom.HTMLElement}s.
*
* @author Harald Pehl
*/
public final class Elements {
// this is a static helper class which must never be instantiated!
private Elements() {}
static class ElementInfo {
int level;
HTMLElement element;
boolean container;
ElementInfo(final HTMLElement element, final boolean container, final int level) {
this.container = container;
this.element = element;
this.level = level;
}
@Override
public String toString() {
return (container ? "container" : "simple") + " @ " + level + ": " + element.tagName;
}
}
/**
* Builder to create a hierarchy of {@link HTMLElement}s. Supports convenience methods to create common elements
* and attributes. Uses a fluent API to create and append elements on the fly.
* <p>
* The builder distinguishes between elements which can contain nested elements (container) and simple element w/o
* children. The former must be closed using {@link #end()}.
* <p>
* In order to create this form,
* <pre>
* <section class="main">
* <input class="toggle-all" type="checkbox">
* <label for="toggle-all">Mark all as complete</label>
* <ul class="todo-list">
* <li>
* <div class="view">
* <input class="toggle" type="checkbox" checked>
* <label>Taste Elemento</label>
* <button class="destroy"></button>
* </div>
* <input class="edit">
* </li>
* </ul>
* </section>
* </pre>
* <p>
* use the following builder code:
* <pre>
* HTMLElement element = new Elements.Builder()
* .section().css("main")
* .input(checkbox).css("toggle-all")
* .label().attr("for", "toggle-all").textContent("Mark all as complete").end()
* .ul().css("todo-list")
* .li()
* .div().css("view")
* .input(checkbox).css("toggle")
* .label().textContent("Taste Elemento").end()
* .button().css("destroy").end()
* .end()
* .input(text).css("edit")
* .end()
* .end()
* .end().build();
* </pre>
*
* @author Harald Pehl
*/
public static class Builder extends CoreBuilder<Builder> {
public Builder() {
super("elements.builder");
}
@Override
protected Builder that() {
return this;
}
}
public static abstract class CoreBuilder<B extends CoreBuilder<B>> {
private final String id;
private final Document document;
private final Stack<ElementInfo> elements;
private final Map<String, HTMLElement> references;
private int level;
/**
* Creates a new builder.
*
* @param id an unique id which is used in error messages
*/
protected CoreBuilder(@NonNls String id) {
this(id, DomGlobal.document);
}
/**
* Creates a new builder.
*
* @param id an unique id which is used in error messages
* @param document a reference to the document
*/
protected CoreBuilder(@NonNls String id, Document document) {
this.id = id;
this.document = document;
this.elements = new Stack<>();
this.references = new HashMap<>();
}
private String logId() {
return "<" + id + "> ";
}
/**
* In order to make builders work with inheritance, sub-builders must return a reference to their instance.
*
* @return {@code this}
*/
protected abstract B that();
// ------------------------------------------------------ container elements
/**
* Starts a new {@code <header>} container. The element must be closed with {@link #end()}.
*/
public B header() {
return start("header");
}
/**
* Starts a new {@code <h&>} container. The element must be closed with {@link #end()}.
*/
public B h(int ordinal) {
return start("h" + ordinal);
}
/**
* Starts a new {@code <h&>} container with the specified inner text.
* The element must be closed with {@link #end()}.
*/
public B h(int ordinal, String text) {
return start("h" + ordinal).textContent(text);
}
/**
* Starts a new {@code <section>} container. The element must be closed with {@link #end()}.
*/
public B section() {
return start((HTMLElement) document.createElement("section"));
}
/**
* Starts a new {@code <aside>} container. The element must be closed with {@link #end()}.
*/
public B aside() {
return start((HTMLElement) document.createElement("aside"));
}
/**
* Starts a new {@code <footer>} container. The element must be closed with {@link #end()}.
*/
public B footer() {
return start((HTMLElement) document.createElement("footer"));
}
/**
* Starts a new {@code <p>} container. The element must be closed with {@link #end()}.
*/
public B p() {
return start((HTMLElement) document.createElement("p"));
}
/**
* Starts a new {@code <ol>} container. The element must be closed with {@link #end()}.
*/
public B ol() {
return start((HTMLElement) document.createElement("ol"));
}
/**
* Starts a new {@code <ul>} container. The element must be closed with {@link #end()}.
*/
public B ul() {
return start((HTMLElement) document.createElement("ul"));
}
/**
* Starts a new {@code <li>} container. The element must be closed with {@link #end()}.
*/
public B li() {
return start((HTMLElement) document.createElement("li"));
}
/**
* Starts a new {@code <a>} container. The element must be closed with {@link #end()}.
*/
public B a() {
return start((HTMLElement) document.createElement("a"));
}
/**
* Starts a new {@code <a>} container with the specified href.
* The element must be closed with {@link #end()}.
*/
public B a(@NonNls String href) {
return start((HTMLElement) document.createElement("a")).attr("href", href);
}
/**
* Starts a new {@code <div>} container. The element must be closed with {@link #end()}.
*/
public B div() {
return start((HTMLElement) document.createElement("div"));
}
/**
* Starts a new {@code <span>} container. The element must be closed with {@link #end()}.
*/
public B span() {
return start((HTMLElement) document.createElement("span"));
}
/**
* Starts the named container. The element must be closed with {@link #end()}.
*/
public B start(@NonNls String tag) {
return start((HTMLElement) document.createElement(tag));
}
/**
* Adds the given element as new container. The element must be closed with {@link #end()}.
*/
public B start(HTMLElement element) {
elements.push(new ElementInfo(element, true, level));
level++;
return that();
}
/**
* Closes the current container element.
*
* @throws IllegalStateException if there's no current element or if the closing element is no container.
*/
public B end() {
assertCurrent();
if (level == 0) {
throw new IllegalStateException(
logId() + "Unbalanced element hierarchy. Elements stack: " + dumpElements());
}
List<ElementInfo> children = new ArrayList<>();
while (elements.peek().level == level) {
children.add(elements.pop());
}
Collections.reverse(children);
if (!elements.peek().container) {
throw new IllegalStateException(
logId() + "Closing element " + currentElement() + " is no container");
}
Element closingElement = currentElement();
for (ElementInfo child : children) {
closingElement.appendChild(child.element);
}
level--;
return that();
}
private String dumpElements() {
return elements.toString();
}
// ------------------------------------------------------ table elements
public B table() {
return start("table");
}
public B thead() {
return start("thead");
}
public B tbody() {
return start("tbody");
}
public B tfoot() {
return start("tfoot");
}
public B tr() {
return start("tr");
}
public B th() {
return start("th");
}
public B td() {
return start("td");
}
// ------------------------------------------------------ form elements
/**
* Starts a new form. The element must be closed with {@link #end()}.
*/
public B form() {
return start((HTMLElement) document.createElement("form"));
}
/**
* Starts a new form label. The element must be closed with {@link #end()}.
*/
public B label() {
return start((HTMLElement) document.createElement("label"));
}
/**
* Starts a new form label with the specified inner text.
* The element must be closed with {@link #end()}.
*/
public B label(String text) {
return start((HTMLElement) document.createElement("label")).textContent(text);
}
/**
* Starts a new button. The element must be closed with {@link #end()}.
*/
public B button() {
return input(InputType.button);
}
/**
* Starts a new button with the specified inner text.
* The element must be closed with {@link #end()}.
*/
public B button(String text) {
return input(InputType.button).textContent(text);
}
/**
* Starts a new select box. The element must be closed with {@link #end()}.
*/
public B select() {
return input(InputType.select);
}
/**
* Starts an option to be used inside a select box. The element must be closed with {@link #end()}.
*/
public B option() {
return start((HTMLElement) document.createElement("option"));
}
/**
* Starts an option with the specified inner text. The element must be closed with {@link #end()}.
*/
public B option(String text) {
return start((HTMLElement) document.createElement("option")).textContent(text);
}
/**
* Starts a new textarea. The element must be closed with {@link #end()}.
*/
public B textarea() {
return input(InputType.textarea);
}
/**
* Creates the given input field. See {@link InputType} for details
* whether a container or simple element is created.
*/
public B input(InputType type) {
switch (type) {
case button:
start((HTMLElement) document.createElement("button"));
break;
case color:
case checkbox:
case date:
case datetime:
case email:
case file:
case hidden:
case image:
case month:
case number:
case password:
case radio:
case range:
case reset:
case search:
case tel:
case text:
case time:
case url:
case week:
HTMLInputElement inputElement = (HTMLInputElement) document.createElement("input");
inputElement.type = type.name();
add(inputElement);
break;
case select:
start((HTMLElement) document.createElement("select"));
break;
case textarea:
start((HTMLElement) document.createElement("textarea"));
break;
}
return that();
}
// ------------------------------------------------------ simple element(s)
/**
* Creates and adds the named element. The element must not be closed using {@link #end()}.
*/
public B add(@NonNls String tag) {
return add((HTMLElement) document.createElement(tag));
}
/**
* Add the given element by calling {@code element.asElement()}. The element must not be closed using {@link
* #end()}.
*/
public B add(IsElement element) {
return add(element.asElement());
}
/**
* Adds all elements from {@code elements.asElements()}.
*/
public B addAll(HasElements elements) {
return addAll(elements.asElements());
}
/**
* Adds all elements.
*/
public B addAll(Iterable<HTMLElement> elements) {
for (HTMLElement element : elements) {
add(element);
}
return that();
}
/**
* Adds the given element. The element must not be closed using {@link #end()}.
*/
public B add(HTMLElement element) {
assertCurrent();
elements.push(new ElementInfo(element, false, level));
return that();
}
// ------------------------------------------------------ modify current element
/**
* Sets the id of the last added element.
*/
public B id(@NonNls String id) {
assertCurrent();
currentElement().id = id;
return that();
}
/**
* Sets the title of the last added element.
*/
public B title(String title) {
assertCurrent();
currentElement().title = title;
return that();
}
/**
* Sets the css classes for the last added element.
*/
public B css(@NonNls String classes) {
//noinspection NullArgumentToVariableArgMethod
return css(classes, null);
}
/**
* Sets the css classes for the last added element.
*/
public B css(@NonNls String first, @NonNls String... rest) {
assertCurrent();
List<String> classes = new ArrayList<>();
classes.add(first);
if (rest != null) {
classes.addAll(asList(rest));
}
currentElement().className = rest != null && rest.length != 0
? Joiner.on(' ').skipNulls().join(classes)
: first;
return that();
}
/**
* Sets the css style for the last added element.
*/
public B style(@NonNls String style) {
assertCurrent();
currentElement().style.cssText = style;
return that();
}
/**
* Adds an attribute to the last added element.
*/
public B attr(@NonNls String name, String value) {
assertCurrent();
currentElement().setAttribute(name, value);
return that();
}
/**
* Adds a {@code data-} attribute to the last added element.
*
* @param name The name of the data attribute w/o the {@code data-} prefix. However it won't be added if it's
* already present.
*/
public B data(@NonNls String name, String value) {
assertCurrent();
String safeName = name.startsWith("data-") ? name.substring("data-".length()) : name;
currentElement().dataset.set(safeName, value);
return that();
}
/**
* Adds an {@code aria-} attribute to the last added element.
*
* @param name The name of the aria attribute w/o the {@code aria-} prefix. However it won't be added if it's
* already present.
*/
public B aria(@NonNls String name, String value) {
String safeName = name.startsWith("aria-") ? name : "aria-" + name;
return attr(safeName, value);
}
/**
* Sets the inner HTML on the last added element.
*/
public B innerHtml(SafeHtml html) {
assertCurrent();
currentElement().innerHTML = html.asString();
return that();
}
/**
* Sets the inner text on the last added element using {@link Element#textContent}.
*
* @deprecated Use {@link #textContent(String)} instead.
*/
@Deprecated
public B innerText(String text) {
assertCurrent();
currentElement().textContent = text;
return that();
}
/**
* Sets the inner text on the last added element using {@link Element#textContent}.
*/
public B textContent(String text) {
assertCurrent();
currentElement().textContent = text;
return that();
}
private void assertCurrent() {
if (elements.isEmpty()) {
throw new IllegalStateException(logId() + "No current element");
}
}
protected HTMLElement currentElement() {
return elements.peek().element;
}
// ------------------------------------------------------ event handler
/**
* Adds the given callback to the the last added element.
*/
public B on(EventType type, EventCallbackFn callback) {
assertCurrent();
type.register(currentElement(), callback);
return that();
}
// ------------------------------------------------------ references
/**
* Stores a named reference for the last added element. The element can be retrieved later on using
* {@link #referenceFor(String)}.
*/
public B rememberAs(@NonNls String id) {
assertCurrent();
references.put(id, currentElement());
return that();
}
/**
* Returns the element which was stored using {@link #rememberAs(String)}.
*
* @throws NoSuchElementException if no element was stored under that id.
*/
@SuppressWarnings("unchecked")
public <T extends Element> T referenceFor(@NonNls String id) {
if (!references.containsKey(id)) {
throw new NoSuchElementException(logId() + "No element reference found for '" + id + "'");
}
return (T) references.get(id);
}
// ------------------------------------------------------ builder
/**
* Builds the element hierarchy and returns the top level element casted to the specified element type.
*
* @param <T> The target element type
*
* @throws IllegalStateException If the hierarchy is unbalanced.
*/
@SuppressWarnings("unchecked")
public <T extends Element> T build() {
if (level != 0 && elements.size() != 1) {
throw new IllegalStateException(
logId() + "Unbalanced element hierarchy. Elements stack: " + dumpElements());
}
return (T) elements.pop().element;
}
/**
* Returns the top level elements added so far. This is useful if you don't want to build a single root
* container, but work with a list of elements.
*/
public Iterable<HTMLElement> elements() {
if (level != 0) {
throw new IllegalStateException(
logId() + "Unbalanced element hierarchy. Elements stack: " + dumpElements());
}
if (elements.isEmpty()) {
throw new IllegalStateException(logId() + "Empty elements stack");
}
//noinspection StaticPseudoFunctionalStyleMethod
return Iterables.transform(this.elements, elementInfo -> elementInfo.element);
}
}
// ------------------------------------------------------ element helper methods
/**
* Returns an iterator over the children of the given parent element. The iterator supports the {@link
* Iterator#remove()} operation which removes the current element from its parent.
*/
public static Iterator<HTMLElement> iterator(HTMLElement parent) {
return parent != null ? new ChildrenIterator(parent) : Collections.<HTMLElement>emptyList().iterator();
}
/**
* Returns a stream for the children of the given parent element.
*/
public static Stream<HTMLElement> stream(HTMLElement parent) {
return parent != null ? StreamSupport.stream(children(parent).spliterator(), false) : Stream.empty();
}
/**
* Returns an iterable collection for the children of the given parent element.
*/
public static Iterable<HTMLElement> children(HTMLElement parent) {
return () -> iterator(parent);
}
/**
* Returns an iterator over the given node list. The iterator will only iterate over elements while skipping nodes.
* The iterator does <strong>not</strong> support the {@link Iterator#remove()} operation.
*/
public static Iterator<HTMLElement> iterator(NodeList<Node> nodes) {
return nodes != null ? new NodeListIterator(nodes) : Collections.<HTMLElement>emptyList().iterator();
}
/**
* Returns a stream for the elements in the given node list.
*/
public static Stream<HTMLElement> stream(NodeList<Node> nodes) {
return nodes != null ? StreamSupport.stream(elements(nodes).spliterator(), false) : Stream.empty();
}
/**
* Returns an iterable collection for the elements in the given node list.
*/
public static Iterable<HTMLElement> elements(NodeList<Node> nodes) {
return () -> iterator(nodes);
}
/**
* Convenience method to set the inner HTML of the given element.
*/
public static void innerHtml(HTMLElement element, SafeHtml html) {
if (element != null) {
element.innerHTML = html.asString();
}
}
/**
* Appends the specified element to the parent element if not already present. If parent already contains child,
* this method does nothing.
*/
public static void lazyAppend(final HTMLElement parent, final HTMLElement child) {
if (!parent.contains(child)) {
parent.appendChild(child);
}
}
/**
* Inserts the specified element into the parent element if not already present. If parent already contains child,
* this method does nothing.
*/
public static void lazyInsertBefore(final HTMLElement parent, final HTMLElement child, final HTMLElement before) {
if (!parent.contains(child)) {
parent.insertBefore(child, before);
}
}
/**
* Removes all child elements from {@code element}
*/
public static void removeChildrenFrom(final HTMLElement element) {
if (element != null) {
while (element.firstChild != null) {
element.removeChild(element.firstChild);
}
}
}
/**
* Removes the element from its parent if the element is not null and has a parent.
*
* @return {@code true} if the the element has been removed from its parent, {@code false} otherwise.
*/
public static boolean failSafeRemoveFromParent(final HTMLElement element) {
return failSafeRemove(element != null ? element.parentNode : null, element);
}
/**
* Removes the child from parent if both parent and child are not null and parent contains child.
*
* @return {@code true} if the the element has been removed from its parent, {@code false} otherwise.
*/
public static boolean failSafeRemove(final Node parent, final HTMLElement child) {
if (parent != null && child != null && parent.contains(child)) {
return parent.removeChild(child) != null;
}
return false;
}
/**
* Looks for an element in the document using the CSS selector {@code [data-element=<name>]}.
*/
public static HTMLElement dataElement(@NonNls String name) {
return (HTMLElement) DomGlobal.document.querySelector("[data-element=" + name + "]");
}
/**
* Looks for an element below {@code context} using the CSS selector {@code [data-element=<name>]}
*/
public static HTMLElement dataElement(HTMLElement context, @NonNls String name) {
return context != null ? (HTMLElement) context.querySelector("[data-element=" + name + "]") : null;
}
/**
* Checks whether the given element is visible (i.e. {@code display} is not {@code none})
*/
public static boolean isVisible(HTMLElement element) {
return element != null && !"none".equals(element.style.display);
}
/**
* shows / hide the specified element by modifying the {@code display} property.
*/
public static void setVisible(HTMLElement element, boolean visible) {
if (element != null) {
element.style.display = visible ? "" : "none";
}
}
/**
* Adds the specified CSS class to the element if {@code condition} is {@code true}, removes it otherwise.
*/
public static void toggle(HTMLElement element, String css, boolean condition) {
if (element != null) {
if (condition) {
element.classList.add(css);
} else {
element.classList.remove(css);
}
}
}
// ------------------------------------------------------ conversions
private static class ElementWidget extends Widget {
ElementWidget(final HTMLElement element) {
setElement(com.google.gwt.dom.client.Element.as(Js.cast(element)));
}
}
/**
* Converts from {@link IsElement} → {@link Widget}.
*/
public static Widget asWidget(IsElement element) {
return asWidget(element.asElement());
}
/**
* Converts from {@link HTMLElement} → {@link Widget}.
*/
public static Widget asWidget(HTMLElement element) {
return new ElementWidget(element);
}
/**
* Converts from {@link IsWidget} → {@link HTMLElement}.
*/
public static HTMLElement asElement(IsWidget widget) {
return asElement(widget.asWidget());
}
/**
* Converts from {@link Widget} → {@link HTMLElement}.
*/
public static HTMLElement asElement(Widget widget) {
return asElement(widget.getElement());
}
/**
* Converts from {@link com.google.gwt.dom.client.Element} → {@link HTMLElement}.
*/
public static HTMLElement asElement(com.google.gwt.dom.client.Element element) {
return Js.cast(element);
}
}