fix: add a per-widget action menu (#27)
CI / build-and-test (pull_request) Successful in 2m25s
CI / build-and-test (pull_request) Successful in 2m25s
A GridStackItem now carries an overflow menu next to the grip and the close button: refresh, maximize, duplicate, remove. Maximize and remove are handled by the item itself; refresh and duplicate are only reported, since what they mean depends on the widget. Maximizing only sets a class — the item's gs-* attributes and its gridstack node are untouched, so restoring is by definition the position it had. The menu sits outside the drag-handle selector, like the close button, so opening it never starts a drag; a Playwright test drags it to prove it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
This commit is contained in:
@@ -3,9 +3,18 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -19,6 +28,13 @@ import java.util.UUID;
|
||||
* 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.
|
||||
*/
|
||||
public class GridStackItem extends Div {
|
||||
|
||||
@@ -30,10 +46,42 @@ public class GridStackItem extends Div {
|
||||
* 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<Action, MenuItem> actionItems = new EnumMap<>(Action.class);
|
||||
private final Map<Action, Span> 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);
|
||||
@@ -77,6 +125,133 @@ public class GridStackItem extends Div {
|
||||
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.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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user