package com.example.components;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentEvent;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.contextmenu.MenuItem;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.menubar.MenuBar;
import com.vaadin.flow.component.menubar.MenuBarVariant;
import com.vaadin.flow.shared.Registration;
import java.util.Collections;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* A single cell inside a {@link GridStackLayout}. Position/size are written as
* {@code gs-*} attributes, which is how gridstack.js reads a widget's initial
* placement when {@code makeWidget} converts it client-side (see grid-stack.ts).
*
* Dragging starts from the grip handle only, never from the item body, so the
* components inside an item stay interactive (see
* {@link GridStackLayout#setDragHandle(String)}). A close button sits next to
* the grip (see {@link #setClosable(boolean)}); removing the item server-side
* is enough to unregister the widget client-side, since grid-stack.ts observes
* its own childList.
*
* Next to those two sits the action menu ({@link Action}): the slot for
* everything a widget can do beyond move/resize/close. {@link Action#MAXIMIZE}
* and {@link Action#REMOVE} are handled here, the rest is only reported to
* {@link #addActionListener(ComponentEventListener) action listeners} — what
* "refresh" or "duplicate" means depends on the widget, which this component
* knows nothing about.
*/
public class GridStackItem extends Div {
/** Selector gridstack is told to treat as the drag handle — see
* {@link GridStackLayout}'s {@code handle} option. */
public static final String DRAG_HANDLE_CLASS = "dialect-drag-handle";
/** Marker class for the close button; {@code styles.css} keys both its own
* styling and the grip's offset off it. */
public static final String CLOSE_BUTTON_CLASS = "dialect-close-button";
/** Marker class for the action menu; the corner controls' offsets in
* {@code styles.css} are keyed off its presence, same as the close
* button's. */
public static final String ACTION_MENU_CLASS = "dialect-action-menu";
/** Class set on the item itself while it is
* {@link #setMaximized(boolean) maximized} — {@code styles.css} is what
* actually lifts the item out of the grid. */
public static final String MAXIMIZED_CLASS = "dialect-maximized";
/** What the action menu can offer. Which entries a given widget shows is up
* to the caller ({@link #setActionEnabled(Action, boolean)}) — not every
* widget type supports every action. */
public enum Action {
/** Re-request this widget's data. Reported only. */
REFRESH,
/** Expand the widget over the grid and back. Handled here. */
MAXIMIZE,
/** Add another widget of the same kind. Reported only. */
DUPLICATE,
/** Same as the close button, from the menu. Handled here. */
REMOVE
}
/** Shown by default: both are widget-type independent, since this component
* can carry them out on its own. */
private static final Action[] DEFAULT_ACTIONS = {Action.MAXIMIZE, Action.REMOVE};
private final Div content = new Div();
private final Div dragHandle = new Div();
private final Div closeButton = new Div();
private final MenuBar actionMenu = new MenuBar();
private final Map actionItems = new EnumMap<>(Action.class);
private final Map actionCaptions = new EnumMap<>(Action.class);
private boolean closable = true;
private boolean maximized;
public GridStackItem(Component... content) {
this(UUID.randomUUID().toString(), 0, 0, 4, 3, content);
}
public GridStackItem(String id, int x, int y, int w, int h, Component... content) {
addClassName("grid-stack-item");
getElement().setAttribute("gs-id", id);
getElement().setAttribute("gs-x", String.valueOf(x));
getElement().setAttribute("gs-y", String.valueOf(y));
getElement().setAttribute("gs-w", String.valueOf(w));
getElement().setAttribute("gs-h", String.valueOf(h));
this.content.addClassName("grid-stack-item-content");
// Attach the wrapper div directly through the element API — calling
// the overridden add(Component...) below would route it through
// `content.add(...)`, making the wrapper try to add itself as its
// own child.
getElement().appendChild(this.content.getElement());
this.content.add(content);
// Sibling of the content wrapper, not a child of it: the wrapper is
// `overflow: auto`, so a handle inside it would scroll out of view.
dragHandle.addClassName(DRAG_HANDLE_CLASS);
dragHandle.add(Fa.DRAG.create());
String label = getTranslation("gridstack.dragHandle");
dragHandle.getElement().setAttribute("title", label);
dragHandle.getElement().setAttribute("aria-label", label);
getElement().appendChild(dragHandle.getElement());
// Sibling of the content wrapper for the same reason as the grip, and
// outside the drag-handle selector so clicking it never starts a drag.
closeButton.addClassName(CLOSE_BUTTON_CLASS);
closeButton.add(Fa.CLOSE.create());
String closeLabel = getTranslation("gridstack.close");
closeButton.getElement().setAttribute("title", closeLabel);
closeButton.getElement().setAttribute("aria-label", closeLabel);
closeButton.getElement().setAttribute("role", "button");
closeButton.getElement().setAttribute("tabindex", "0");
closeButton.getElement().addEventListener("click", e -> close(true));
closeButton.getElement().addEventListener("keydown", e -> close(true))
.setFilter("event.key === 'Enter' || event.key === ' '");
getElement().appendChild(closeButton.getElement());
buildActionMenu();
getElement().appendChild(actionMenu.getElement());
}
/**
* The overflow menu, a third sibling of the content wrapper: outside it for
* the same reason as the grip, and outside the {@code DRAG_HANDLE_CLASS}
* selector so opening it never starts a drag.
*
* A {@link MenuBar} rather than a hand-rolled popup: its button is a real
* button, so the menu is reachable and operable from the keyboard without
* this component re-implementing any of it.
*/
private void buildActionMenu() {
actionMenu.addClassName(ACTION_MENU_CLASS);
actionMenu.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE, MenuBarVariant.LUMO_ICON);
String label = getTranslation("gridstack.actions");
MenuItem root = actionMenu.addItem(Fa.MENU.create());
root.getElement().setAttribute("title", label);
root.getElement().setAttribute("aria-label", label);
addActionItem(root, Action.REFRESH, Fa.REFRESH, "gridstack.refresh");
addActionItem(root, Action.MAXIMIZE, Fa.MAXIMIZE, "gridstack.maximize");
addActionItem(root, Action.DUPLICATE, Fa.DUPLICATE, "gridstack.duplicate");
addActionItem(root, Action.REMOVE, Fa.REMOVE, "gridstack.remove");
setActions(DEFAULT_ACTIONS);
}
/** The caption is a {@link Span} of its own rather than the item's text:
* {@code setText} would drop the icon along with the old text, and the
* maximize entry relabels itself. */
private void addActionItem(MenuItem root, Action action, Fa icon, String captionKey) {
Span caption = new Span(getTranslation(captionKey));
MenuItem item = root.getSubMenu().addItem(icon.create(),
e -> triggerAction(action, e.isFromClient()));
item.add(caption);
actionItems.put(action, item);
actionCaptions.put(action, caption);
}
/** Carries out what this component can do itself, then reports the action.
* The event is fired last, so a listener sees the item in its new state —
* and, for {@link Action#REMOVE}, already detached, as with
* {@link CloseEvent}. */
private void triggerAction(Action action, boolean fromClient) {
switch (action) {
case MAXIMIZE -> setMaximized(!maximized);
case REMOVE -> close(fromClient);
default -> { }
}
fireEvent(new ActionEvent(this, fromClient, action));
}
/** Shows exactly the given actions in the menu and hides every other one. */
public GridStackItem setActions(Action... actions) {
Set wanted = EnumSet.noneOf(Action.class);
Collections.addAll(wanted, actions);
actionItems.forEach((action, item) -> item.setVisible(wanted.contains(action)));
return this;
}
/** Shows or hides a single menu entry — the widget types that support an
* action differ, the menu does not. */
public GridStackItem setActionEnabled(Action action, boolean enabled) {
actionItems.get(action).setVisible(enabled);
return this;
}
public boolean isActionEnabled(Action action) {
return actionItems.get(action).isVisible();
}
/** The menu entry of an action. Exposed for restyling and for tests, which
* click it rather than calling the action directly. */
public MenuItem getActionItem(Action action) {
return actionItems.get(action);
}
public MenuBar getActionMenu() {
return actionMenu;
}
/**
* Expands the item over the grid, or puts it back. Only a class on the item
* changes — its {@code gs-*} attributes and its gridstack node are left
* exactly as they are, so restoring is by definition the position it had.
*/
public GridStackItem setMaximized(boolean maximized) {
if (maximized == this.maximized) {
return this;
}
this.maximized = maximized;
getElement().getClassList().set(MAXIMIZED_CLASS, maximized);
actionCaptions.get(Action.MAXIMIZE).setText(
getTranslation(maximized ? "gridstack.restore" : "gridstack.maximize"));
// The item's box changes without gridstack resizing anything, so nothing
// fires the resize the charts inside reflow on (see grid-stack.ts,
// which does the same on resizestop). After the next frame, so the new
// geometry is the one the chart measures.
getElement().executeJs("requestAnimationFrame("
+ "() => window.dispatchEvent(new Event('resize')))");
return this;
}
public boolean isMaximized() {
return maximized;
}
public Registration addActionListener(ComponentEventListener listener) {
return addListener(ActionEvent.class, listener);
}
/** Fired after an action menu entry was picked. */
public static class ActionEvent extends ComponentEvent {
private final Action action;
ActionEvent(GridStackItem source, boolean fromClient, Action action) {
super(source, fromClient);
this.action = action;
}
public Action getAction() {
return action;
}
}
/**
* Detaches this item from its layout and notifies
* {@link #addCloseListener(ComponentEventListener) close listeners}. Called
* by the close button, and usable server-side to close an item
* programmatically.
*/
public void close() {
close(false);
}
private void close(boolean fromClient) {
// Detach first so listeners observe the layout they are about to see —
// getLayout() on the grid no longer counts this item.
getElement().removeFromParent();
fireEvent(new CloseEvent(this, fromClient));
}
/** Shows or hides the close button (shown by default). */
public GridStackItem setClosable(boolean closable) {
if (closable == this.closable) {
return this;
}
this.closable = closable;
if (closable) {
getElement().appendChild(closeButton.getElement());
} else {
closeButton.getElement().removeFromParent();
}
return this;
}
public boolean isClosable() {
return closable;
}
/** The close button element. Exposed for restyling, the same way
* {@link #getDragHandle()} is. */
public Div getCloseButton() {
return closeButton;
}
public Registration addCloseListener(ComponentEventListener listener) {
return addListener(CloseEvent.class, listener);
}
/** Fired after the item has been removed from its {@link GridStackLayout}. */
public static class CloseEvent extends ComponentEvent {
CloseEvent(GridStackItem source, boolean fromClient) {
super(source, fromClient);
}
}
public void add(Component... components) {
content.add(components);
}
public void removeAll() {
content.removeAll();
}
public String getItemId() {
return getElement().getAttribute("gs-id");
}
public GridStackItem setResizable(boolean resizable) {
setBooleanAttribute("gs-no-resize", !resizable);
return this;
}
/** Also hides the grip handle when {@code false} — the {@code gs-no-move}
* attribute written here is what {@code styles.css} keys the handle's
* visibility off. */
public GridStackItem setMovable(boolean movable) {
setBooleanAttribute("gs-no-move", !movable);
return this;
}
/** The element gridstack drags the item by. Exposed so callers can restyle
* or reposition it; removing it from the DOM makes the item immovable. */
public Div getDragHandle() {
return dragHandle;
}
/** Writes an explicit "true" (gridstack reads this via its own
* {@code Utils.toBool}) or removes the attribute — avoids relying on
* Vaadin's HTML5 empty-string boolean-attribute convention, which
* gridstack's own boolean parsing does not document matching. */
private void setBooleanAttribute(String name, boolean value) {
if (value) {
getElement().setAttribute(name, "true");
} else {
getElement().removeAttribute(name);
}
}
public GridStackItem setMinSize(int minWidth, int minHeight) {
getElement().setAttribute("gs-min-w", String.valueOf(minWidth));
getElement().setAttribute("gs-min-h", String.valueOf(minHeight));
return this;
}
public GridStackItem setPosition(int x, int y, int w, int h) {
getElement().setAttribute("gs-x", String.valueOf(x));
getElement().setAttribute("gs-y", String.valueOf(y));
getElement().setAttribute("gs-w", String.valueOf(w));
getElement().setAttribute("gs-h", String.valueOf(h));
return this;
}
/** Snapshot of a widget's grid placement, as reported by the client after a
* drag/resize. */
public record Position(String id, int x, int y, int w, int h) {
}
}