fix: add a per-widget action menu (#27)
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:
Pit Friedrich
2026-07-28 21:26:05 +02:00
parent cff232070d
commit 8545b06f41
11 changed files with 537 additions and 8 deletions
@@ -27,6 +27,10 @@ public enum Fa {
RESET("fa-solid", "fa-arrow-rotate-left"),
DRAG("fa-solid", "fa-grip-vertical"),
CLOSE("fa-solid", "fa-xmark"),
MENU("fa-solid", "fa-ellipsis-vertical"),
REFRESH("fa-solid", "fa-arrows-rotate"),
MAXIMIZE("fa-solid", "fa-expand"),
DUPLICATE("fa-solid", "fa-clone"),
TREND_UP("fa-solid", "fa-arrow-trend-up"),
TREND_DOWN("fa-solid", "fa-arrow-trend-down");
@@ -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;
}
}
/**
@@ -3,6 +3,7 @@ package com.example.views;
import com.example.components.Card;
import com.example.components.Fa;
import com.example.components.GridStackItem;
import com.example.components.GridStackItem.Action;
import com.example.components.GridStackLayout;
import com.example.components.KpiTile;
import com.example.data.ChartDataService;
@@ -11,6 +12,7 @@ import com.example.data.KpiData;
import com.example.widgets.DashboardContext;
import com.example.widgets.WidgetDefinition;
import com.example.widgets.WidgetRegistry;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
@@ -78,7 +80,16 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
KpiData kpi = kpis.get(i);
KpiTile tile = feed(new KpiTile(getTranslation(kpi.labelKey()), ""), kpi);
kpiTiles.put(kpi.id(), tile);
grid.add(new GridStackItem(kpi.id(), i * KPI_WIDTH, 0, KPI_WIDTH, 1, tile));
GridStackItem item = new GridStackItem(kpi.id(), i * KPI_WIDTH, 0, KPI_WIDTH, 1, tile);
// A tile can be refreshed (this view feeds it) but not duplicated:
// a second copy of the same KPI would be the same number twice.
item.setActionEnabled(Action.REFRESH, true);
item.addActionListener(e -> {
if (e.getAction() == Action.REFRESH) {
refreshKpiTile(kpi.id());
}
});
grid.add(item);
}
context.addFilterChangeListener(this::updateKpiTiles);
@@ -153,10 +164,32 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
return widget(type, widgets.require(type), x, y, w, h);
}
/** Registry widgets support the whole action menu: they know how to
* re-request their data, and the definition they were built from is what
* duplicating one needs. */
private GridStackItem widget(String id, WidgetDefinition definition,
int x, int y, int w, int h) {
return new GridStackItem(id, x, y, w, h, new Card(getTranslation(definition.titleKey()),
definition.factory().apply(context)));
Component content = definition.factory().apply(context);
GridStackItem item = new GridStackItem(id, x, y, w, h,
new Card(getTranslation(definition.titleKey()), content));
item.setActions(Action.REFRESH, Action.MAXIMIZE, Action.DUPLICATE, Action.REMOVE);
item.addActionListener(e -> {
switch (e.getAction()) {
case REFRESH -> WidgetRegistry.refresh(content);
case DUPLICATE -> addWidget(definition);
default -> { }
}
});
return item;
}
/** Re-feeds a single tile from the current filter — the action menu's
* refresh, which asks for one widget, not for the dashboard. */
private void refreshKpiTile(String kpiId) {
dataService.kpis(context.getFilter()).stream()
.filter(kpi -> kpiId.equals(kpi.id()))
.findFirst()
.ifPresent(kpi -> feed(kpiTiles.get(kpi.id()), kpi));
}
/** Re-feeds the tiles still on the dashboard. A closed tile keeps its entry
@@ -9,6 +9,7 @@ import com.example.data.ChartDataService;
import com.example.data.ChartSeries;
import com.example.data.DashboardFilter;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentUtil;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.shared.Registration;
import org.springframework.stereotype.Service;
@@ -54,6 +55,27 @@ public class WidgetRegistry {
this::pieChart));
}
/**
* How a built widget re-requests its data — the same feed the context
* drives on a filter change, only triggered by hand (the action menu's
* refresh). It is carried on the widget component itself, so a caller can
* refresh a widget without knowing which type it is or holding the feed.
*/
@FunctionalInterface
public interface Refresh {
void run();
}
/** Re-requests the widget's data, if it is one that can. A widget without a
* refresher (a static card, say) is silently left alone — the dashboard
* offers the action per widget, this only carries it out. */
public static void refresh(Component widget) {
Refresh refresh = ComponentUtil.getData(widget, Refresh.class);
if (refresh != null) {
refresh.run();
}
}
/** Adds a definition, replacing any earlier one of the same type. */
public final void register(WidgetDefinition definition) {
definitions.put(definition.type(), definition);
@@ -107,6 +129,8 @@ public class WidgetRegistry {
feed.accept(context.getFilter());
Registration registration = context.addFilterChangeListener(feed);
chart.addDetachListener(e -> registration.remove());
ComponentUtil.setData(chart, Refresh.class,
(Refresh) () -> feed.accept(context.getFilter()));
return sizeFull(chart);
}
@@ -302,10 +302,40 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
transition: opacity 120ms ease;
}
.grid-stack-item:has(> .dialect-close-button) > .dialect-drag-handle {
/* Action menu (GridStackItem.Action): the third corner control, between the
close button and the grip. Each control that is present pushes the ones to
its left one slot (32px) further in. */
.dialect-action-menu {
position: absolute;
top: 11px;
right: 16px;
z-index: 1;
opacity: 0;
transition: opacity 120ms ease;
}
.grid-stack-item:has(> .dialect-close-button) > .dialect-action-menu {
right: 48px;
}
.grid-stack-item:has(> .dialect-close-button) > .dialect-drag-handle,
.grid-stack-item:has(> .dialect-action-menu) > .dialect-drag-handle {
right: 48px;
}
.grid-stack-item:has(> .dialect-close-button):has(> .dialect-action-menu)
> .dialect-drag-handle {
right: 80px;
}
/* Stays put while its menu is open: the overlay is anchored to the button, and
the pointer moving onto the overlay leaves the item, ending the :hover. */
.grid-stack-item:hover > .dialect-action-menu,
.dialect-action-menu:focus-within,
.dialect-action-menu:has(vaadin-menu-bar-button[expanded]) {
opacity: 0.75;
}
.grid-stack-item:hover > .dialect-close-button,
.dialect-close-button:focus-visible {
opacity: 0.65;
@@ -320,11 +350,38 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
/* Touch devices never hover — keep the corner controls permanently visible. */
@media (pointer: coarse) {
.dialect-drag-handle,
.dialect-close-button {
.dialect-close-button,
.dialect-action-menu {
opacity: 0.65;
}
}
/* Maximized item (GridStackItem#setMaximized): lifted out of the grid purely
visually — its gs-* attributes and its gridstack node are untouched, so
dropping this class puts it back exactly where it was. !important because
gridstack writes the item's box as inline style. */
.grid-stack-item.dialect-maximized {
position: fixed !important;
inset: 16px !important;
width: auto !important;
height: auto !important;
min-height: 0 !important;
transform: none !important;
z-index: 20;
}
.grid-stack-item.dialect-maximized > .grid-stack-item-content {
height: 100%;
inset: 0 !important;
width: auto !important;
}
/* Nothing to drag or resize while maximized: the item is not in grid flow. */
.grid-stack-item.dialect-maximized > .dialect-drag-handle,
.grid-stack-item.dialect-maximized .ui-resizable-handle {
display: none;
}
.grid-stack-item[gs-no-move] > .dialect-drag-handle {
display: none;
}
@@ -22,7 +22,7 @@ card.registration=Registrierung
card.employees=Mitarbeiter
card.gridstackHint=Bedienung
gridstack.hint=Karten am Griff oben rechts verschieben, an der unteren rechten Ecke die Größe ändern, mit dem X oben rechts schließen. Das Layout wird im Browser gespeichert und beim nächsten Besuch wiederhergestellt.
gridstack.hint=Karten am Griff oben rechts verschieben, an der unteren rechten Ecke die Größe ändern, mit dem X oben rechts schließen. Weitere Aktionen aktualisieren, maximieren, duplizieren liegen im Menü daneben. Das Layout wird im Browser gespeichert und beim nächsten Besuch wiederhergestellt.
gridstack.addWidget=Widget hinzufügen
gridstack.reset=Layout zurücksetzen
gridstack.status=Layout geändert {0} Widgets
@@ -30,6 +30,12 @@ gridstack.statusInitial=Layout unverändert
gridstack.pickerTitle=Widget auswählen
gridstack.dragHandle=Verschieben
gridstack.close=Schließen
gridstack.actions=Aktionen
gridstack.refresh=Aktualisieren
gridstack.maximize=Maximieren
gridstack.restore=Wiederherstellen
gridstack.duplicate=Duplizieren
gridstack.remove=Entfernen
filter.period=Zeitraum
filter.period.month=Monat
@@ -22,7 +22,7 @@ card.registration=Registration
card.employees=Employees
card.gridstackHint=How it works
gridstack.hint=Move cards with the grip in their top right corner, resize them from the bottom right corner, close them with the X in the top right corner. The layout is stored in your browser and restored on your next visit.
gridstack.hint=Move cards with the grip in their top right corner, resize them from the bottom right corner, close them with the X in the top right corner. More actions refresh, maximize, duplicate live in the menu next to it. The layout is stored in your browser and restored on your next visit.
gridstack.addWidget=Add widget
gridstack.reset=Reset layout
gridstack.status=Layout changed {0} widgets
@@ -30,6 +30,12 @@ gridstack.statusInitial=Layout unchanged
gridstack.pickerTitle=Choose a widget
gridstack.dragHandle=Move
gridstack.close=Close
gridstack.actions=Actions
gridstack.refresh=Refresh
gridstack.maximize=Maximize
gridstack.restore=Restore
gridstack.duplicate=Duplicate
gridstack.remove=Remove
filter.period=Period
filter.period.month=Month
@@ -22,7 +22,7 @@ card.registration=Registro
card.employees=Empleados
card.gridstackHint=Cómo funciona
gridstack.hint=Mueve las tarjetas con el asa de la esquina superior derecha, cambia su tamaño desde la esquina inferior derecha y ciérralas con la X de la esquina superior derecha. El diseño se guarda en el navegador y se restaura en la próxima visita.
gridstack.hint=Mueve las tarjetas con el asa de la esquina superior derecha, cambia su tamaño desde la esquina inferior derecha y ciérralas con la X de la esquina superior derecha. Más acciones actualizar, maximizar, duplicar están en el menú contiguo. El diseño se guarda en el navegador y se restaura en la próxima visita.
gridstack.addWidget=Añadir widget
gridstack.reset=Restablecer diseño
gridstack.status=Diseño modificado {0} widgets
@@ -30,6 +30,12 @@ gridstack.statusInitial=Diseño sin cambios
gridstack.pickerTitle=Elegir un widget
gridstack.dragHandle=Mover
gridstack.close=Cerrar
gridstack.actions=Acciones
gridstack.refresh=Actualizar
gridstack.maximize=Maximizar
gridstack.restore=Restaurar
gridstack.duplicate=Duplicar
gridstack.remove=Eliminar
filter.period=Periodo
filter.period.month=Mes