Создание веб-компонента

Web Components можно создавать с нуля на основе библиотек Polymer 3 или Lit. После реализации веб-компонента на JavaScript, для него можно создать Java API.

Пример ниже показывает, как построить переключатель светлой/темной темы на основе Lit.

Создание JavaScript веб-компонента

Создайте файл theme-toggle.js в папке frontend/src/component/theme-toggle. Он содержит веб-компонент, реализующий переключатель светлой/темной темы.

import {html} from 'lit';
import {Button} from '@vaadin/button';
import {defineCustomElement} from '@vaadin/component-base/src/define.js';

export class ThemeToggle extends Button { (1)

    static get is() {
        return 'theme-toggle'; (2)
    }

    static get template() { (3)
        return html`
            <div class="vaadin-button-container">
                <span part="prefix" aria-hidden="true">
                    <slot name="prefix"></slot>
                </span>
                <span part="label">
                    <slot></slot>
                </span>
            </div>

            <slot name="tooltip"></slot>
        `;
    }

    static get properties() { (4)
        return {
            ariaLabel: {
                type: String,
                value: 'Theme toggle',
                reflectToAttribute: true,
            },
            storageKey: {
                type: String,
                value: 'jmix.flowui.theme',
                observer: '_onStorageKeyChanged'
            }
        };
    }

    constructor() {
        super();

        this.addEventListener('click', () => this.toggleTheme());
        this.addEventListener('click', () => {
            const customEvent = new CustomEvent('theme-changed', {detail: {value: this.getCurrentTheme()}});
            this.dispatchEvent(customEvent); (5)
        });
    }

    /** @protected */
    ready() {
        super.ready();
        this.applyStorageTheme();
    }

    applyStorageTheme() {
        let storageTheme = this.getStorageTheme();
        let currentTheme = this.getCurrentTheme();
        if (storageTheme && currentTheme !== storageTheme) {
            this.applyTheme(storageTheme);
        }
    }

    getStorageTheme() {
        return localStorage.getItem(this.storageKey);
    }

    getCurrentTheme() {
        return document.documentElement.getAttribute('theme');
    }

    toggleTheme() {
        const theme = this.getCurrentTheme();
        this.applyTheme(theme === 'dark' ? '' : 'dark');
    }

    applyTheme(theme) {
        document.documentElement.setAttribute('theme', theme);
        localStorage.setItem(this.storageKey, theme);
    }

    /** @protected */
    _onStorageKeyChanged(storageKey, oldStorageKey) {
        const theme = localStorage.getItem(oldStorageKey);
        localStorage.removeItem(oldStorageKey);

        if (theme) {
            localStorage.setItem(storageKey, theme);
        }
    }
}

defineCustomElement(ThemeToggle); (6)
1 Расширяет Vaadin Button, чтобы переиспользовать поведение и стилизацию кнопки.
2 Определяет имя HTML-элемента.
3 Определяет шаблон Shadow DOM, включая слоты для иконки, текста и всплывающей подсказки.
4 Определяет клиентские свойства, включая ключ local storage, используемый для сохранения выбранной темы.
5 Отправляет событие theme-changed после переключения темы компонентом.
6 Экспортирует пользовательский HTML-элемент с именем, определённым в методе static get is().

Создание Java API для веб-компонента

Создайте файл ThemeToggle.java, который является классом UI-компонента. Он определяет API для серверного кода, методы доступа, обработчики событий и подключение источников данных.

import com.vaadin.flow.component.*;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.shared.HasTooltip;
import com.vaadin.flow.dom.Element;
import com.vaadin.flow.shared.Registration;
import io.jmix.flowui.kit.component.HasTitle;

import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Tag("theme-toggle") (1)
@JsModule("./src/component/theme-toggle/theme-toggle.js") (2)
public class ThemeToggle extends Component implements ClickNotifier<ThemeToggle>,
        Focusable<ThemeToggle>, HasTheme, HasEnabled, HasSize, HasStyle,
        HasText, HasTooltip, HasTitle, HasAriaLabel { (3)

    public static final String STORAGE_KEY_PROPERTY = "storageKey";
    public static final String THEME_CHANGED_EVENT = "theme-changed";
    protected Component iconComponent;

    public ThemeToggle() {
        setIcon(createDefaultIcon());
    }

    public ThemeToggle(Component icon) {
        setIcon(icon);
    }

    public ThemeToggle(String text, Component icon) {
        setIcon(icon);
        setText(text);
    }

    protected Icon createDefaultIcon() {
        Icon icon = VaadinIcon.ADJUST.create();
        icon.getElement().getStyle().set("rotate", "180deg");
        return icon;
    }

    @Override
    public void setText(String text) {
        removeAll(getNonTextNodes());
        if (text != null && !text.isEmpty()) {
            getElement().appendChild(Element.createText(text));
        }
        updateThemeAttribute();
    }

    public Component getIcon() {
        return iconComponent;
    }

    public void setIcon(Component icon) {
        if (icon != null && icon.getElement().isTextNode()) {
            throw new IllegalArgumentException("Text node can't be used as an icon.");
        }
        if (iconComponent != null) {
            remove(iconComponent);
        }

        iconComponent = icon;
        if (icon != null) {
            add(icon);
            updateIconSlot();
        }

        updateThemeAttribute();
    }

    protected void updateIconSlot() {
        iconComponent.getElement().setAttribute("slot", "prefix");
    }

    protected void add(Component... components) {
        for (Component component : components) {
            getElement().appendChild(component.getElement());
        }
    }

    protected void remove(Component... components) {
        for (Component component : components) {
            if (getElement().equals(component.getElement().getParent())) {
                component.getElement().removeAttribute("slot");
                getElement().removeChild(component.getElement());
            } else {
                throw new IllegalArgumentException(
                        "The given component (" + component + ") is not a child of this component");
            }
        }
    }

    public boolean isAutofocus() {
        return getElement().getProperty("autofocus", false);
    }

    public void setAutofocus(boolean autofocus) {
        getElement().setProperty("autofocus", autofocus);
    }

    public String getStorageKey() {
        return getElement().getProperty(STORAGE_KEY_PROPERTY);
    }

    public void setStorageKey(String storageKey) {
        getElement().setProperty(STORAGE_KEY_PROPERTY, storageKey);
    }

    protected void removeAll(Element... exclusion) {
        Set<Element> toExclude = Stream.of(exclusion)
                .collect(Collectors.toSet());
        Predicate<Element> filter = toExclude::contains;

        getElement().getChildren()
                .filter(filter.negate())
                .forEach(child -> child.removeAttribute("slot"));

        getElement().removeAllChildren();
        getElement().appendChild(exclusion);
    }

    protected Element[] getNonTextNodes() {
        return getElement().getChildren()
                .filter(element -> !element.isTextNode())
                .toArray(Element[]::new);
    }

    protected void updateThemeAttribute() {
        long childCount = getElement().getChildren()
                .filter(element ->
                        element.isTextNode() || !"vaadin-tooltip".equals(element.getTag()))
                .count();

        if (childCount == 1 && iconComponent != null) {
            getThemeNames().add("icon");
        } else {
            getThemeNames().remove("icon");
        }
    }

    public Registration addThemeChangeListener(ComponentEventListener<ThemeToggleThemeChangedEvent> listener) {
        return addListener(ThemeToggleThemeChangedEvent.class, listener);
    }

    @DomEvent(THEME_CHANGED_EVENT) (4)
    public static class ThemeToggleThemeChangedEvent extends ComponentEvent<ThemeToggle> {

        protected String value;

        public ThemeToggleThemeChangedEvent(ThemeToggle source, boolean fromClient,
                                            @EventData("event.detail.value") String value) { (5)
            super(source, fromClient);
            this.value = value;
        }

        public String getValue() {
            return value;
        }
    }
}
1 Определяет корневой элемент, который автоматически создается классом Component и к которому можно обращаться с помощью метода getElement(). Должен совпадать с экспортом веб-компонента.
2 Аннотация @JsModule определяет импорт JavaScript-модуля.
3 Использование Vaadin Mixin Interfaces для предоставления общих API и поведения по умолчанию для наборов функциональности, встречающихся в большинстве веб-компонентов.
4 Использование аннотации @DomEvent для подключения компонента ThemeToggle к событию DOM theme-changed.
5 Использование аннотации @EventData для определения дополнительных данных события, в данном случае значения темы.
Более подробную информацию о создании пользовательских компонентов можно найти в документации Vaadin: Creating Components, Using Vaadin Mixin Interfaces, Using Events with Components.

Пример использования

После реализации компонента его можно использовать в экранах, например:

XML Дескриптор

<view xmlns="http://jmix.io/schema/flowui/view"
      xmlns:app="http://company.com/schema/app-ui-components"
      title="msg://themeToggleView.title"> (1)
    <layout>
        <app:themeToggle text="Click to switch theme"/> (2)
    </layout>
</view>
1 Определяет пространство имен со значением, совпадающим со значением атрибутов xmlns и targetNamespace XSD компонента.
2 Добавляет элемент themeToggle с префиксом пространства имен.

Программное использование

@Subscribe
public void onInit(final InitEvent event) {
    ThemeToggle themeToggle = new ThemeToggle();
    themeToggle.setText("Click to switch theme");
    themeToggle.addThemeChangeListener(changedEvent ->
            notifications.create("Theme switched: " + getThemeValue(changedEvent))
                    .withPosition(Notification.Position.TOP_CENTER)
                    .show());
    getContent().add(themeToggle);
}