4878a7fc06
CI / build-and-test (pull_request) Successful in 2m19s
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
447 lines
19 KiB
Java
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) {
|
|
}
|
|
}
|