com.vaadin.flow.component.contextmenu.ContextMenuBase Maven / Gradle / Ivy
/*
* Copyright 2000-2024 Vaadin Ltd.
*
* 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.vaadin.flow.component.contextmenu;
import java.util.List;
import java.util.stream.Stream;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentEvent;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.HasComponents;
import com.vaadin.flow.component.HasStyle;
import com.vaadin.flow.component.Synchronize;
import com.vaadin.flow.component.Tag;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.page.PendingJavaScriptResult;
import com.vaadin.flow.dom.DomEvent;
import com.vaadin.flow.function.SerializableRunnable;
import com.vaadin.flow.shared.Registration;
import elemental.json.JsonObject;
/**
* Base functionality for server-side components based on
* {@code }. Classes extending this should provide API for
* adding items and handling events related to them. For basic example, see
* {@link ContextMenu}.
*
* @param
* the context-menu type
* @param
* the menu-item type
* @param
* the sub menu type
*
* @author Vaadin Ltd.
*/
@SuppressWarnings("serial")
@Tag("vaadin-context-menu")
@NpmPackage(value = "@vaadin/polymer-legacy-adapter", version = "24.4.9")
@JsModule("@vaadin/polymer-legacy-adapter/style-modules.js")
@NpmPackage(value = "@vaadin/context-menu", version = "24.4.9")
@JsModule("@vaadin/context-menu/src/vaadin-context-menu.js")
@JsModule("./flow-component-renderer.js")
@JsModule("./contextMenuConnector.js")
@JsModule("./contextMenuTargetConnector.js")
public abstract class ContextMenuBase, I extends MenuItemBase, S extends SubMenuBase>
extends Component implements HasComponents, HasStyle {
public static final String EVENT_DETAIL = "event.detail";
private Component target;
private MenuManager menuManager;
private MenuItemsArrayGenerator menuItemsArrayGenerator;
private String openOnEventName = "vaadin-contextmenu";
private Registration targetBeforeOpenRegistration;
private Registration targetAttachRegistration;
private PendingJavaScriptResult targetJsRegistration;
private boolean autoAddedToTheUi;
/**
* Creates an empty context menu.
*/
public ContextMenuBase() {
// Workaround for: https://github.com/vaadin/flow/issues/3496
getElement().setProperty("opened", false);
// Don't open the overlay immediately with any event, let
// contextMenuConnector.js make a server round-trip first.
getElement().setProperty("openOn", "none");
getElement().addEventListener("opened-changed", event -> {
if (autoAddedToTheUi && !isOpened()) {
getElement().removeFromParent();
autoAddedToTheUi = false;
}
});
getElement().addPropertyChangeListener("opened", event -> {
fireEvent(new OpenedChangeEvent<>((C) this,
event.isUserOriginated()));
});
menuItemsArrayGenerator = new MenuItemsArrayGenerator<>(this);
addAttachListener(event -> {
String appId = event.getUI().getInternals().getAppId();
initConnector(appId);
resetContent();
});
}
/**
* Sets the target component for this context menu.
*
* By default, the context menu can be opened with a right click or a long
* touch on the target component.
*
* @param target
* the target component for this context menu, can be
* {@code null} to remove the target
*/
public void setTarget(Component target) {
if (getTarget() != null) {
targetBeforeOpenRegistration.remove();
targetAttachRegistration.remove();
getTarget().getElement().executeJs(
"if (this.$contextMenuTargetConnector) { this.$contextMenuTargetConnector.removeConnector() }");
if (isTargetJsPending()) {
targetJsRegistration.cancelExecution();
targetJsRegistration = null;
}
}
this.target = target;
getElement().getNode().runWhenAttached(
ui -> ui.beforeClientResponse(this, context -> ui.getPage()
.executeJs("$0.listenOn=$1", this, target)));
if (target == null) {
return;
}
// Target's JavaScript needs to be executed on each attach,
// because Flow creates a new client-side element
target.getUI().ifPresent(this::onTargetAttach);
targetAttachRegistration = target
.addAttachListener(e -> onTargetAttach(e.getUI()));
// Server round-trip before opening the overlay
targetBeforeOpenRegistration = target.getElement()
.addEventListener("vaadin-context-menu-before-open",
this::beforeOpenHandler)
.addEventData(EVENT_DETAIL);
}
/**
* Gets the target component of this context menu, or {@code null} if it
* doesn't have a target.
*
* @return the target component of this context menu
* @see #setTarget(Component)
*/
public Component getTarget() {
return target;
}
/**
* Determines the way for opening the context menu.
*
* By default, the context menu can be opened with a right click or a long
* touch on the target component.
*
* @param openOnClick
* if {@code true}, the context menu can be opened with left
* click only. Otherwise the context menu follows the default
* behavior.
*/
public void setOpenOnClick(boolean openOnClick) {
openOnEventName = openOnClick ? "click" : "vaadin-contextmenu";
requestTargetJsExecutions();
}
/**
* Gets whether the context menu can be opened via left click.
*
* By default, this will return {@code false} and context menu can be opened
* with a right click or a long touch on the target component.
*
* @return {@code true} if the context menu can be opened with left click
* only. Otherwise the context menu follows the default behavior.
*/
public boolean isOpenOnClick() {
return "click".equals(openOnEventName);
}
/**
* Closes this context menu if it is currently open.
*/
public void close() {
// See https://github.com/vaadin/flow-components/issues/6449
getElement().setProperty("opened", false);
getElement().callJsFunction("close");
}
/**
* Adds a new item component with the given text content to the context menu
* overlay.
*
* This is a convenience method for the use case where you have a list of
* highlightable {@link MenuItem}s inside the overlay. If you want to
* configure the contents of the overlay without wrapping them inside
* {@link MenuItem}s, or if you just want to add some non-highlightable
* components between the items, use the {@link #add(Component...)} method.
*
* @param text
* the text content for the created menu item
* @return the created menu item
* @see #add(Component...)
*/
public I addItem(String text) {
return getMenuManager().addItem(text);
}
/**
* Adds a new item component with the given component to the context menu
* overlay.
*
* This is a convenience method for the use case where you have a list of
* highlightable {@link MenuItem}s inside the overlay. If you want to
* configure the contents of the overlay without wrapping them inside
* {@link MenuItem}s, or if you just want to add some non-highlightable
* components between the items, use the {@link #add(Component...)} method.
*
* @param component
* the component to add to the created menu item
* @return the created menu item
* @see #add(Component...)
*/
public I addItem(Component component) {
return getMenuManager().addItem(component);
}
/**
* Adds the given components into the context menu overlay.
*
* For the common use case of having a list of high-lightable items inside
* the overlay, you can use the {@link #addItem(String)} convenience methods
* instead.
*
* The added elements in the DOM will not be children of the
* {@code } element, but will be inserted into an
* overlay that is attached into the {@code }.
*
* @param components
* the components to add
* @see HasMenuItems#addItem(String, ComponentEventListener)
* @see HasMenuItems#addItem(Component, ComponentEventListener)
*/
@Override
public void add(Component... components) {
getMenuManager().add(components);
}
@Override
public void remove(Component... components) {
getMenuManager().remove(components);
}
/**
* Removes all of the child components. This also removes all the items
* added with {@link #addItem(String)} and its overload methods.
*/
@Override
public void removeAll() {
getMenuManager().removeAll();
}
/**
* Adds the given component into this context menu at the given index.
*
* The added elements in the DOM will not be children of the
* {@code } element, but will be inserted into an
* overlay that is attached into the {@code }.
*
* @param index
* the index, where the component will be added
* @param component
* the component to add
* @see #add(Component...)
*/
@Override
public void addComponentAtIndex(int index, Component component) {
getMenuManager().addComponentAtIndex(index, component);
}
/**
* Gets the child components of this component. This includes components
* added with {@link #add(Component...)} and the {@link MenuItem} components
* created with {@link #addItem(String)} and its overload methods. This
* doesn't include the components added to the sub menus of this context
* menu.
*
* @return the child components of this component
*/
@Override
public Stream getChildren() {
return getMenuManager().getChildren();
}
/**
* Gets the items added to this component (the children of this component
* that are instances of {@link MenuItem}). This doesn't include the
* components added to the sub menus of this context menu.
*
* @return the {@link MenuItem} components in this context menu
* @see #addItem(String)
*/
public List getItems() {
return getMenuManager().getItems();
}
/**
* Gets the open state from the context menu.
*
* This property is synchronized automatically from client side when an
* {@code opened-changed} event happens.
*
* @return the {@code opened} property from the context menu
*/
@Synchronize(property = "opened", value = "opened-changed")
public boolean isOpened() {
return getElement().getProperty("opened", false);
}
/**
* {@code opened-changed} event is sent when the overlay opened state
* changes.
*/
public static class OpenedChangeEvent>
extends ComponentEvent {
private final boolean opened;
public OpenedChangeEvent(TComponent source, boolean fromClient) {
super(source, fromClient);
this.opened = source.isOpened();
}
public boolean isOpened() {
return opened;
}
}
/**
* Adds a listener for the {@code opened-changed} events fired by the web
* component.
*
* @param listener
* the listener to add
* @return a Registration for removing the event listener
*/
public Registration addOpenedChangeListener(
ComponentEventListener> listener) {
return addListener(OpenedChangeEvent.class,
(ComponentEventListener) listener);
}
/**
* Gets the menu manager.
*
* @return the menu manager
*/
protected MenuManager getMenuManager() {
if (menuManager == null) {
menuManager = createMenuManager(this::resetContent);
}
return menuManager;
}
/**
* Creates a menu manager instance which contains logic to control the menu
* content.
*
* @param contentReset
* callback to reset the menu content
* @return a new menu manager instance
*/
protected abstract MenuManager createMenuManager(
SerializableRunnable contentReset);
/**
* Decides whether to open the menu when the
* {@link ContextMenuBase#beforeOpenHandler(DomEvent)} is processed,
* sub-classes can easily override it and perform additional operations in
* this phase.
*
* The event details are completely specified by the target component that
* is in charge of defining the data it sends to the server. Based on this
* information, this method enables for dynamically modifying the contents
* of the context menu. Furthermore, this method's return value specifies if
* the context menu will be opened.
*
*
* @param eventDetail
* the client side event details provided by the target
* component.
*
* @return {@code true} if the context menu should be opened, {@code false}
* otherwise.
*/
protected boolean onBeforeOpenMenu(JsonObject eventDetail) {
return true;
}
private void resetContent() {
menuItemsArrayGenerator.generate();
}
private void onTargetAttach(UI ui) {
ui.getInternals().addComponentDependencies(ContextMenu.class);
requestTargetJsExecutions();
}
/*
* Used for both initializing the client-side connector and to update the
* openOn-event. This ensures that openOn is never updated before the
* connector is initialized.
*/
private void requestTargetJsExecutions() {
if (target != null) {
if (isTargetJsPending()) {
targetJsRegistration.cancelExecution();
}
targetJsRegistration = target.getElement().executeJs(
"window.Vaadin.Flow.contextMenuTargetConnector.init(this);"
+ "this.$contextMenuTargetConnector.updateOpenOn($0);",
openOnEventName);
}
}
private boolean isTargetJsPending() {
return targetJsRegistration != null
&& !targetJsRegistration.isSentToBrowser();
}
private void beforeOpenHandler(DomEvent event) {
JsonObject eventDetail = event.getEventData().getObject(EVENT_DETAIL);
boolean shouldOpenMenu = onBeforeOpenMenu(eventDetail);
if (shouldOpenMenu) {
addContextMenuToUi();
target.getElement().callJsFunction(
"$contextMenuTargetConnector.openMenu", getElement());
}
}
private void addContextMenuToUi() {
if (getElement().getNode().getParent() == null) {
UI ui = getCurrentUI();
ui.beforeClientResponse(ui, context -> {
ui.addToModalComponent(this);
autoAddedToTheUi = true;
});
}
}
private UI getCurrentUI() {
UI ui = UI.getCurrent();
if (ui == null) {
throw new IllegalStateException("UI instance is not available. "
+ "It means that you are calling this method "
+ "out of a normal workflow where it's always implicitly set. "
+ "That may happen if you call the method from the custom thread without "
+ "'UI::access' or from tests without proper initialization.");
}
return ui;
}
private void initConnector(String appId) {
getElement().executeJs(
"window.Vaadin.Flow.contextMenuConnector.initLazy(this, $0)",
appId);
}
}