Files
chart-app/src/main/java/com/example/components/GridStackItem.java
T
Pit Friedrich 4878a7fc06
CI / build-and-test (pull_request) Successful in 2m19s
fix: undo closing a widget (#40)
Closing a widget was instant and destructive - one misclick on the X
lost its size and position. Close now shows a toast with an undo
button that re-adds the very same widget at its captured gs-x/y/w/h,
keeping its gs-id. Programmatic close() (fromClient=false) is skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGRChzremwYCUctf2qMrQz
2026-07-28 23:25:13 +02:00

447 lines
19 KiB
Java

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).
* <p>
* 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.
* <p>
* 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<Action, MenuItem> actionItems = new EnumMap<>(Action.class);
private final Map<Action, Span> actionCaptions = new EnumMap<>(Action.class);
private final Map<Action, Anchor> actionLinks = 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.
* <p>
* 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<Action> wanted = EnumSet.noneOf(Action.class);
Collections.addAll(wanted, actions);
actionItems.forEach((action, item) -> item.setVisible(wanted.contains(action)));
return this;
}
/**
* Makes an entry hand out a file: its caption becomes a download link over
* {@code handler}, so picking it downloads instead of only firing an
* {@link ActionEvent} (which it still does — a listener can react to the
* export as well).
* <p>
* 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<ActionEvent> listener) {
return addListener(ActionEvent.class, listener);
}
/** Fired after an action menu entry was picked. */
public static class ActionEvent extends ComponentEvent<GridStackItem> {
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) {
// Captured before detaching: once removeFromParent() runs, the grid no
// longer reports this item, so its position could not be read back from
// there afterwards. The gs-* attributes themselves are untouched by the
// detach, so a caller that keeps this item around and re-adds it later
// (e.g. to undo the close) lands it back at exactly this spot.
Position position = capturePosition();
// 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, position));
}
private Position capturePosition() {
return new Position(getItemId(),
attrInt("gs-x"), attrInt("gs-y"), attrInt("gs-w"), attrInt("gs-h"));
}
private int attrInt(String name) {
String value = getElement().getAttribute(name);
return value == null ? 0 : Integer.parseInt(value);
}
/** 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<CloseEvent> listener) {
return addListener(CloseEvent.class, listener);
}
/** Fired after the item has been removed from its {@link GridStackLayout}.
* Carries the {@link Position} it held right before detaching, so a
* listener can offer to undo the close (re-adding the item at that exact
* spot) without having to track positions itself. {@link #isFromClient()}
* tells apart a user-initiated close (button/menu) from a programmatic
* {@link GridStackItem#close()} — an undo affordance only makes sense for
* the former. */
public static class CloseEvent extends ComponentEvent<GridStackItem> {
private final Position position;
CloseEvent(GridStackItem source, boolean fromClient, Position position) {
super(source, fromClient);
this.position = position;
}
public Position getPosition() {
return position;
}
}
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) {
}
}