Skip to content

hal/elemento

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Verify Codebase Javadoc Maven Central GWT3/J2CL compatible Chat on Gitter

Elemento

Elemento simplifies working with Elemental2. In a nutshell Elemento brings the following features to the table:

TOC

Get Started

Elemento is available in Maven Central. The easiest way is to import its BOM

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.jboss.elemento</groupId>
            <artifactId>elemento-bom</artifactId>
            <version>1.6.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

and add a dependency to either

<dependency>
    <groupId>org.jboss.elemento</groupId>
    <artifactId>elemento-core</artifactId>
    <type>gwt-lib</type>
</dependency>

or

<dependency>
    <groupId>org.jboss.elemento</groupId>
    <artifactId>elemento-core</artifactId>
</dependency>

depending on your stack. If you're using GWT, inherit from org.jboss.elemento.Core:

<module>
    <inherits name="org.jboss.elemento.Core"/>
</module>

Builder API

When working with GWT Elemental it is often awkward and cumbersome to create a hierarchy of elements. Even simple structures like

<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>

lead to a vast amount of Document.createElement() and chained Node.appendChild() calls. With Elemento creating the above structure is as easy as

import static org.jboss.elemento.Elements.*;
import static org.jboss.elemento.InputType.checkbox;
import static org.jboss.elemento.InputType.text;

HTMLElement section = section().css("main")
        .add(input(checkbox).id("toggle-all").css("toggle-all"))
        .add(label()
                .apply(l -> l.htmlFor = "toggle-all")
                .textContent("Mark all as complete"))
        .add(ul().css("todo-list")
                .add(li()
                        .add(div().css("view")
                                .add(input(checkbox)
                                        .css("toggle")
                                        .checked(true))
                                .add(label().textContent("Taste Elemento"))
                                .add(button().css("destroy")))
                        .add(input(text).css("edit"))))
        .element();

The class Elements provides convenience methods to create the most common elements. It uses a fluent API to create and append elements on the fly. Take a look at the API documentation for more details.

References

When creating large hierarchies of elements you often need to assign an element somewhere in the tree. Use an inline assignment together with element() to create and assign the element in one go:

import static org.jboss.elemento.Elements.*;

final HTMLElement count;
final HTMLElement footer = footer()
        .add(count = span().css("todo-count").element())
        .element();

Event Handlers

Elemento provides methods to easily register event handlers. There are constants for most of the known event types. You can either add event handlers when building the element hierarchy:

import static org.jboss.elemento.Elements.*;
import static org.jboss.elemento.EventType.*;
import static org.jboss.elemento.InputType.checkbox;
import static org.jboss.elemento.InputType.text;

HTMLLIElement listItem = li()
        .add(div().css("view")
                .add(input(checkbox)
                        .css("toggle")
                        .on(change, event -> toggle()))
                .add(label()
                        .textContent("Taste Elemento")
                        .on(dblclick, event -> edit()))
                .add(button()
                        .css("destroy")
                        .on(click, event -> destroy())))
        .add(input(text)
                .css("edit")
                .on(keydown, this::keyDown)
                .on(blur, event -> blur()))
        .element();

or register them later using EventType.bind():

import org.gwtproject.event.shared.HandlerRegistration;

import static elemental2.dom.DomGlobal.alert;
import static org.jboss.elemento.EventType.bind;
import static org.jboss.elemento.EventType.click;

HandlerRegistration handler = bind(listItem, click, event -> alert("Clicked"));

The latter approach returns org.gwtproject.event.shared.HandlerRegistration which you can use to remove the handler again.

In order to make it easier to work with keyboard events, Elemento provides an enum with the most common keyboard codes:

import elemental2.dom.KeyboardEvent;

import static org.jboss.elemento.Key.Escape;
import static org.jboss.elemento.Key.Enter;

void keyDown(KeyboardEvent event) {
    if (Escape.match(event)) {
        ...
    } else if (Enter.match(event)) {
        ...
    }
}

Typesafe CSS Selectors

Elemento provides a typesafe selector API. It can be used to express simple CSS selector like .class or #id up to complex selectors like

#main [data-list-item|=foo] a[href^="https://"] > .fas.fa-check, .external[hidden]

This selector can be created with

import org.jboss.elemento.By;

import static org.jboss.elemento.By.AttributeOperator.CONTAINS_TOKEN;
import static org.jboss.elemento.By.AttributeOperator.STARTS_WITH;

By selector = By.group(
        By.id("main")
                .desc(By.data("listItem", CONTAINS_TOKEN, "foo")
                        .desc(By.element("a").and(By.attribute("href", STARTS_WITH, "https://"))
                                .child(By.classname(new String[]{"fas", "fa-check"})))),
        By.classname("external").and(By.attribute("hidden"))
);

The selector can be used to find single or all HTML elements:

import org.jboss.elemento.By;

import static org.jboss.elemento.By.AttributeOperator.STARTS_WITH;
import static org.jboss.elemento.Elements.a;
import static org.jboss.elemento.Elements.body;

By selector = By.element("a").and(By.attribute("href", STARTS_WITH, "https://"));
for (HTMLElement element : body().findAll(selector)) {
    a(element).css("external");
}

Custom Elements

Elemento makes it easy to create custom elements. As for Elemento custom elements are a composite of HTML elements and / or other custom elements. They're ordinary classes which can hold state or register event handlers. The only requirement is to implement IsElement<E extends HTMLElement> and return a root element:

import static org.jboss.elemento.Elements.*;

class TodoItemElement implements IsElement<HTMLElement> {

    private final HTMLElement root;
    private final HTMLInputElement toggle;
    private final HTMLElement label;
    private final HTMLInputElement summary;

    TodoItemElement(TodoItem item) {
        this.root = li().data("item", item.id)
                .add(div().css("view")
                        .add(toggle = input(checkbox).css("toggle")
                                .checked(item.completed)
                                .element())
                        .add(label = label().textContent(item.text).element())
                        .add(destroy = button().css("destroy").element()))
                .add(summary = input(text).css("edit").element())
                .element();
        this.root.classList.toggle("completed", item.completed);
    }

    @Override
    public HTMLElement element() {
        return root;
    }

    // event handlers omitted
}

The builder API has support for IsElement<E extends HTMLElement> which makes it easy to use custom elements when building the element hierarchy:

import static org.jboss.elemento.Elements.ul;

TodoItemRepository repository = ...;
TodoItemElement[] itemElements = repository.items().stream()
        .map(TodoItemElement::new)
        .toArray();

ul().addAll(itemElements).element();

Goodies

Besides the builder API, Elemento comes with a bunch of static helper methods that roughly fall into these categories:

  1. Get notified when an element is attached to and detached from the DOM tree.
  2. Iterate over elements.
  3. Methods to manipulate the DOM tree (add, insert and remove elements).
  4. Methods to manipulate an element.
  5. Methods to generate safe IDs.

See the API documentation of Elements for more details.

Attach / Detach

Implement Attachable to get notified when an element is attached to and detached from the DOM tree. The attachable interface provides a static method to easily register the callbacks to attach(MutationRecord) and detach(MutationRecord):

import elemental2.dom.MutationRecord;

import org.jboss.elemento.Attachable;
import org.jboss.elemento.IsElement;

import static elemental2.dom.DomGlobal.console;
import static org.jboss.elemento.Elements.li;

class TodoItemElement implements IsElement<HTMLElement>, Attachable {

    private final HTMLElement root;

    TodoItemElement(TodoItem item) {
        this.root = li().element();
        Attachable.register(root, this);
    }

    @Override
    public HTMLElement element() {
        return root;
    }

    @Override
    public void attach(MutationRecord mutationRecord) {
        console.log("Todo item has been attached");
    }

    @Override
    public void detach(MutationRecord mutationRecord) {
        console.log("Todo item has been detached");
    }
}

Elemento uses the MutationObserver API to detect changes in the DOM tree and passes an MutationRecord instance to the attach(MutationRecord) and detach(MutationRecord) methods. This instance contains additional information about the DOM manipulation.

Important

The attach(MutationRecord) and detach(MutationRecord) methods are only called once after the attachable implementation has been registered with Attachable.register(HTMLElement, Attachable). If you hold a reference to the element, and the element is attached to the DOM again, the callbacks are not called again, unless the element has been registered again. This is done for reasons of optimisation and performance.

Tip

If you want to be notified every time your custom element is attached to the DOM it is recommended to add the call to Attachable.register(HTMLElement, Attachable) in the constructor like in the example above and recreate the custom element.

Iterators / Iterables / Streams

Elemento provides several methods to iterate over node lists, child elements or elements returned by a selector. There are methods which return Iterator, Iterable and Stream.

Methods returning java.util.Iterator

  • Elements.iterator(JsArrayLike<E> nodes)
    Returns an iterator over the given array-like. The iterator does not support the Iterator.remove() operation.

  • Elements.iterator(Node parent)
    Returns an iterator over the children of parent. The iterator supports the Iterator.remove() operation, which removes the current node from its parent.

Methods returning java.lang.Iterable

  • Elements.elements(JsArrayLike<E> nodes)
    Returns an iterable for the nodes in the given array-like.

  • Elements.children(elemental2.dom.Node parent)
    Returns an iterable for the child nodes of parent.

Methods returning java.util.stream.Stream

  • Elements.stream(JsArrayLike<E> nodes)
    Returns a stream for the nodes in the given array-like.

  • Elements.stream(elemental2.dom.Node parent)
    Returns a stream for the child nodes of parent.

Manipulate the DOM Tree

  • Elements.lazyAppend(Element parent, Element child)
    Appends child to parent. If parent already contains child, this method does nothing.

  • Elements.insertAfter(Element newElement, Element after)
    Inserts newElement into after's parent after after.

  • Elements.lazyInsertAfter(Element newElement, Element after)
    Inserts newElement into after's parent after after. If after's parent already contains newElement, this method does nothing.

  • Elements.insertBefore(Element newElement, Element before)
    Inserts newElement into before's parent before before.

  • Elements.lazyInsertBefore(Element new Element, Element before)
    Inserts newElement into before's parent before before. If before's parent already contains newElement, this method does nothing.

  • Elements.insertFirst(Element parent, Element newElement)
    Inserts newElement as first element into parent.

  • Elements.removeChildrenFrom(Element element)
    Removes all child elements from element

  • Elements.failSafeRemove(Node parent, Element child)
    Removes child from parent.

  • Elements.failSafeRemoveFromParent(Element element)
    Removes element from its parent.

All methods are null-safe, check parent/child relationships, and are overloaded to accept an instance of IsElement<Element> instead of Element. See the API documentation of Elements for more details.

SVG & MathML

Elemento comes with basic support for SVG and MathML.

SVG

To create SVG elements, add the following dependency to your POM:

<dependency>
    <groupId>org.jboss.elemento</groupId>
    <artifactId>elemento-svg</artifactId>
    <version>1.6.0</version>
</dependency>

In your GWT module inherit from org.jboss.elemento.SVG:

<module>
    <inherits name="org.jboss.elemento.SVG"/>
</module>

Finally, use the static methods in org.jboss.elemento.svg.SVG to create SVG elements.

MathML

To create MathML elements, add the following dependency to your POM:

<dependency>
    <groupId>org.jboss.elemento</groupId>
    <artifactId>elemento-mathml</artifactId>
    <version>1.6.0</version>
</dependency>

In your GWT module inherit from org.jboss.elemento.MathML:

<module>
    <inherits name="org.jboss.elemento.MathML"/>
</module>

Finally, use the static methods in org.jboss.elemento.mathml.MathML to create MathML elements.

Flow

The class Flow provides methods to execute a list of asynchronous tasks in parallel or sequentially, or to execute a single task repeatedly as long as certain conditions are met.

See the API documentation of org.jboss.elemento.flow for more details.

Parallel

// datetime format is "2022-03-31T11:03:39.348365+02:00"
Task<FlowContext> currentTime = context -> fetch("https://worldtimeapi.org/api/timezone/Europe/Berlin")
        .then(Response::json)
        .then(json -> Promise.resolve(Js.<JsPropertyMap<String>>cast(json).get("datetime").substring(11, 23)))
        .then(context::resolve);
double ms = 500 + new Random().nextInt(2000);
Task<FlowContext> delay = context -> new Promise<>((res, __) -> setTimeout(___ -> res.onInvoke(context), ms));

// execute the two tasks in parallel
Flow.parallel(new FlowContext(), List.of(currentTime, delay))
        .subscribe(context -> console.log("Current time: " + context.pop("n/a")));

Sequential

// datetime format is "2022-03-31T11:03:39.348365+02:00"
Task<FlowContext> currentTime = context -> fetch("https://worldtimeapi.org/api/timezone/Europe/Berlin")
        .then(Response::json)
        .then(json -> Promise.resolve(Js.<JsPropertyMap<String>>cast(json).get("datetime").substring(11, 23)))
        .then(context::resolve);
double ms = 500 + new Random().nextInt(2_000);
Task<FlowContext> delay = context -> new Promise<>((res, __) -> setTimeout(___ -> res.onInvoke(context), ms));

// execute the two tasks in sequence and cancel after 1_000 ms
Flow.parallel(new FlowContext(), List.of(currentTime, delay))
        .timeout(1_000)
        .subscribe(context -> console.log("Current time: " + context.pop("n/a")));

Repeated

Task<FlowContext> currentTime = context -> fetch("https://worldtimeapi.org/api/timezone/Europe/Berlin")
        .then(Response::json)
        .then(json -> Promise.resolve(Js.<JsPropertyMap<String>>cast(json).get("datetime").substring(11, 23)))
        .then(context::resolve);

// fetch the current time until the milliseconds end with "0" and cancel after 5 iterations
Flow.repeat(new FlowContext(),currentTime)
        .while_(context -> !context.pop("").endsWith("0"))
        .iterations(5)
        .subscribe(context -> console.log("Current time: " + context.pop("n/a")));

Router

Elemento offers a very basic router. The router is minimal invasive and built around a few simple concepts:

  • Place
    Data class that represents a place in an application. A place is identified by a route, and can have an optional title and a custom root element. Routes can have parameters like in /contacts/:contactId. Finally, a place can also have a loader assigned to it.

  • Places
    Builder to set up places. Supports nested places, combining places and allows to assign loaders to places.

  • LoadData<T>
    Functional interface to asynchronously load data before a page is added to the DOM. The function gets the place and parameters as input, returns a promise of the data to be loaded and is defined as Promise<T> load(Place place, Parameter parameter).

  • Page
    Simple interface that represents a collection of HTML elements. Implementations should be cheap to create and need to implement a single method: Iterable<HTMLElement> elements(Place, Parameter, LoadedData).

  • PlaceManager
    Main class of the router module. performs the following tasks, among others:

    • keeps track of registered places
    • handles navigation events
    • parses route parameters
    • calls loader functions
    • updates the DOM and browser history

The place manager can be customized using builder like methods and has a start() method to show the initial page.

Annotations

Creating a Places instance can be time-consuming (especially in large applications). This can be automated by using annotations, where an annotation processor takes over the tedious work of creating the Places instance. You can use the following annotations for this purpose:

  • @Route
    Annotation to mark a Page implementation as place. The annotation requires a route and can have an optional title, root selector and reference to a named loader. If the annotated page has exactly one public, static method that returns LoadData<?> and accepts no parameters, this is used as loader for the place. If a loader is specified using the loader attribute, there has to be a matching class or static method annotated with @Loader.

  • @Loader
    Annotation to mark a class or public static method as a named loader. If used on a class, the class has to implement LoadData<T>. If used on a static method, the method has to return LoadData<?> and must not accept any parameters.

You can also mix and match an own Places instance with the generated one (see below).

Here's an example showing most of the concepts in action:

@Route("/time/:area/:location")
public class TimePage implements Page {

    public static LoadData<String> loadTime() {
        return (place, parameter) -> {
            String area = parameter.get("area");
            String location = parameter.get("location");
            String url = "https://worldtimeapi.org/api/timezone/" + area + "/" + location;
            return fetch(url)
                    .then(Response::json)
                    .then(json -> {
                        JsPropertyMap<String> map = Js.cast(json);
                        return Promise.resolve(map.get("datetime"));
                    });
        };
    }

    @Override
    public Iterable<HTMLElement> elements(Place place, Parameter parameter, LoadedData data) {
        String area = parameter.get("area");
        String location = parameter.get("location");
        String currentTime = data.get();
        return asList(
                h(1, "Current time").element(),
                p()
                        .add("It's ")
                        .add(span().textContent(currentTime))
                        .add(" in " + area + "/" + location)
                        .element());
    }
}

@Route("/", "Home")
public class HomePage implements Page {

    @Override
    public Iterable<HTMLElement> elements(Place place, Parameter parameter, LoaderData data) {
        return asList(
                h(1, "Welcome").element(),
                p()
                        .add("What time is it in ")
                        .add(a("/time/Europe/Berlin").textNode("Berlin"))
                        .add("?")
                        .element());
    }
}

public class Application {

    public void entryPoint() {
        body().add(div().id("main"));

        Places allPlaces = places()
                .add(new AnnotatedPlaces()) // generated places instance
                .add(place("/foo"), FooPage::new)
                .add(place("/bar"), BarPage::new)
                .children("/level1", places()
                        .add(place("/"), Level1Page::new) // index page
                        .add(places() // siblings
                                .add(place("/foo"), Foo1Page::new)
                                .add(place("/bar"), Bar1Page::new))
                        .children("/level2", places()
                                .add(place("/foo"), Foo2Page::new)
                                .add(place("/bar"), Bar2Page::new)));

        PlaceManager placeManager = new PlaceManager()
                .root(By.id("main"))
                .register(allPlaces);
        placeManager.start();
    }
}

See the API documentation of PlaceManager for more details.

Logger

Elemento contains a small wrapper around console.log that uses categories, log levels, and a predefined log format.

The different log methods delegate to the corresponding methods in console:

  1. Logger.error(String, Object... params)console.error()
  2. Logger.warn(String, Object... params)console.warn()
  3. Logger.info(String, Object... params)console.info()
  4. Logger.debug(String, Object... params)console.debug()

Get loggers

To get a logger use Logger.getLogger(String category).

package org.acme;

public class Foo {

    private static final Logger logger = Logger.getLogger(Foo.class.getName());
}

You can use an arbitrary string as category. By using a hierarchical category, you can override subcategories. String substitutions are supported, and you can pass a variable list of parameters to the log methods.

The log level is set globally for all categories using Logger.setLevel(Level level). You can override the level for one category using Logger.setLevel(String category, Level level). To reset a category, use Logger.resetLevel(String category). If the category contains ., it is interpreted hierarchically. This means that if the category org.jboss is overridden, this is also applied to all subcategories (unless overridden otherwise).

Log format

The log format is predefined as

HH:mm:ss.SSS <level> [<category>] <message>

and cannot be customized. If the category is a fully qualified class name, the package names are shortened. In any case the category is trimmed, and right aligned.

Controlling log levels from JavaScript

The logger module exports some methods with slightly adjusted signatures to JavaScript. You can use them for instance in the browser dev tools to control the global and category based log levels:

  • org.jboss.elemento.logger.Logger.setLevel(String level)
    Sets the global log level

  • org.jboss.elemento.logger.Logger.setLevel(String category, String level)
    Overrides the log level for one category

  • org.jboss.elemento.logger.Logger.resetLevel(String category)
    Resets the log level for the category to the global log level

Please use the fully qualified name!

Samples

Elemento comes with different sample applications to showcase and test the various modules. They're available at https://hal.github.io/elemento/samples/. The source code is available the samples folder.

Contributing

If you want to contribute to Elemento, please follow the steps in contribution.

Get Help

If you need help feel free to contact us at Gitter, browse the API documentation or file an issue.