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.Anchor; import com.vaadin.flow.component.html.AttachmentType; 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.server.streams.DownloadHandler; 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. An entry that hands out a file rather than changing the
* widget is wired with
* {@link #setActionDownload(Action, DownloadHandler) setActionDownload}.
*/
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";
/** Marker class for the download link of a
* {@link #setActionDownload(Action, DownloadHandler) download entry};
* {@code styles.css} stretches it over the whole entry, so the whole row
* is clickable and not just its caption. */
public static final String ACTION_LINK_CLASS = "dialect-action-link";
/** 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,
/** Download the data behind the widget. Reported only — the file itself
* comes from the {@link #setActionDownload(Action, DownloadHandler)
* download handler} the caller wired to this entry. */
EXPORT,
/** 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
* 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.EXPORT, Fa.EXPORT, "gridstack.export");
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
* The handler is asked for its content when the entry is clicked, not here,
* so a widget that has been re-fed in the meantime exports what it is
* showing at that moment. Calling this again re-points the same link.
*/
public GridStackItem setActionDownload(Action action, DownloadHandler handler) {
Anchor link = actionLinks.computeIfAbsent(action, key -> {
Anchor anchor = new Anchor();
anchor.addClassName(ACTION_LINK_CLASS);
// Reparents the caption into the link: the entry keeps its icon and
// its text, only the text is now what the browser downloads from.
anchor.add(actionCaptions.get(key));
actionItems.get(key).add(anchor);
return anchor;
});
link.setHref(handler, AttachmentType.DOWNLOAD);
return this;
}
/** The download link of an entry wired with
* {@link #setActionDownload(Action, DownloadHandler)}, if it has one. */
public Anchor getActionLink(Action action) {
return actionLinks.get(action);
}
/** 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