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 actionItems = new EnumMap<>(Action.class); private final Map actionCaptions = new EnumMap<>(Action.class); private final Map 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. *

* 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 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). *

* 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 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) { // 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 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 { 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) { } }