fix: make dashboard widgets closable (#14)
CI / build-and-test (pull_request) Successful in 1m27s

Every GridStackItem now renders a close button next to its drag grip,
mirroring the grip's hover affordance. Closing detaches the item
server-side; grid-stack.ts already unregisters widgets via its
MutationObserver, so the layout is persisted without an extra protocol.

Closable is on by default and can be turned off per item with
setClosable(false); GridStackItem.CloseEvent lets views react.

The GridStackView "remove last" toolbar button is dropped — per-widget
close replaces it, so the view no longer tracks a widget stack. Its
counter now only grows, keeping a closed widget's id from being handed
to a new widget (which would inherit the saved position).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue9ZtWUBQF4SuSHpzZ3zwq
This commit is contained in:
Pit Friedrich
2026-07-26 20:17:05 +02:00
parent 2e2c9aec3b
commit 7d23eef81b
8 changed files with 185 additions and 37 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ public enum Fa {
ADD("fa-solid", "fa-plus"),
REMOVE("fa-solid", "fa-trash"),
RESET("fa-solid", "fa-arrow-rotate-left"),
DRAG("fa-solid", "fa-grip-vertical");
DRAG("fa-solid", "fa-grip-vertical"),
CLOSE("fa-solid", "fa-xmark");
private final String[] classes;
@@ -1,7 +1,10 @@
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.html.Div;
import com.vaadin.flow.shared.Registration;
import java.util.UUID;
@@ -12,7 +15,10 @@ import java.util.UUID;
* <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)}).
* {@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.
*/
public class GridStackItem extends Div {
@@ -20,8 +26,14 @@ public class GridStackItem extends Div {
* {@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";
private final Div content = new Div();
private final Div dragHandle = new Div();
private final Div closeButton = new Div();
private boolean closable = true;
public GridStackItem(Component... content) {
this(UUID.randomUUID().toString(), 0, 0, 4, 3, content);
@@ -51,6 +63,72 @@ public class GridStackItem extends Div {
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());
}
/**
* 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) {
// 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));
}
/** 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}. */
public static class CloseEvent extends ComponentEvent<GridStackItem> {
CloseEvent(GridStackItem source, boolean fromClient) {
super(source, fromClient);
}
}
public void add(Component... components) {
@@ -18,14 +18,13 @@ import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
/**
* Showcase for {@link GridStackLayout}: a dashboard of draggable/resizable
* cards whose layout is persisted per browser, plus controls to add, remove
* and reset widgets at runtime.
* cards whose layout is persisted per browser, plus controls to add widgets and
* reset the layout at runtime. Widgets are removed by their own close button
* (see {@link GridStackItem#setClosable(boolean)}), not from the toolbar.
*/
@Route("gridstack")
public class GridStackView extends VerticalLayout implements HasDynamicTitle {
@@ -33,7 +32,6 @@ public class GridStackView extends VerticalLayout implements HasDynamicTitle {
private static final String STORAGE_KEY = "gridstack-demo";
private final GridStackLayout grid = new GridStackLayout();
private final Deque<GridStackItem> extraWidgets = new ArrayDeque<>();
private final Span status = new Span();
private int extraWidgetCount;
@@ -67,13 +65,10 @@ public class GridStackView extends VerticalLayout implements HasDynamicTitle {
e -> addWidget());
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button remove = new Button(getTranslation("gridstack.removeWidget"), Fa.REMOVE.create(),
e -> removeWidget());
Button reset = new Button(getTranslation("gridstack.reset"), Fa.RESET.create(),
e -> grid.resetLayout());
HorizontalLayout toolbar = new HorizontalLayout(add, remove, reset, status);
HorizontalLayout toolbar = new HorizontalLayout(add, reset, status);
toolbar.setPadding(false);
toolbar.setWidthFull();
toolbar.setAlignItems(Alignment.CENTER);
@@ -83,21 +78,13 @@ public class GridStackView extends VerticalLayout implements HasDynamicTitle {
private void addWidget() {
// The id must stay stable across reloads for the saved layout to match
// it again, so it is derived from a counter rather than a random UUID.
// The counter only ever grows: closing a widget must not hand its id to
// the next one, or the new widget would inherit the closed one's saved
// position.
extraWidgetCount++;
String title = getTranslation("gridstack.widget", extraWidgetCount);
GridStackItem item = new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2,
new Card(title, new Paragraph(getTranslation("gridstack.widgetText"))));
extraWidgets.push(item);
grid.add(item);
}
private void removeWidget() {
GridStackItem item = extraWidgets.poll();
if (item == null) {
return;
}
grid.remove(item);
extraWidgetCount--;
grid.add(new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2,
new Card(title, new Paragraph(getTranslation("gridstack.widgetText")))));
}
private Component lineChart() {
@@ -176,9 +176,45 @@ vaadin-app-layout::part(content) {
opacity: 1;
}
/* Touch devices never hover — keep the grip permanently visible there. */
/* Close button (GridStackItem#setClosable): same affordance as the grip, in
the outermost corner slot. The grip steps aside only when it is there, so a
non-closable item keeps its grip flush with the corner. */
.dialect-close-button {
position: absolute;
top: 14px;
right: 16px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--dialect-ink);
opacity: 0;
cursor: pointer;
transition: opacity 120ms ease;
}
.grid-stack-item:has(> .dialect-close-button) > .dialect-drag-handle {
right: 48px;
}
.grid-stack-item:hover > .dialect-close-button,
.dialect-close-button:focus-visible {
opacity: 0.65;
}
.dialect-close-button:hover,
.dialect-close-button:active {
opacity: 1;
color: var(--dialect-primary);
}
/* Touch devices never hover — keep the corner controls permanently visible. */
@media (pointer: coarse) {
.dialect-drag-handle {
.dialect-drag-handle,
.dialect-close-button {
opacity: 0.65;
}
}
@@ -24,15 +24,15 @@ 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. 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. Das Layout wird im Browser gespeichert und beim nächsten Besuch wiederhergestellt.
gridstack.addWidget=Widget hinzufügen
gridstack.removeWidget=Letztes entfernen
gridstack.reset=Layout zurücksetzen
gridstack.status=Layout geändert {0} Widgets
gridstack.statusInitial=Layout unverändert
gridstack.widget=Widget {0}
gridstack.widgetText=Frei platzierbare Karte.
gridstack.dragHandle=Verschieben
gridstack.close=Schließen
chart.revenueSeries=Umsatz 2026
chart.pointClick=Serie {0}, Punkt {1}
@@ -24,15 +24,15 @@ 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. 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. The layout is stored in your browser and restored on your next visit.
gridstack.addWidget=Add widget
gridstack.removeWidget=Remove last
gridstack.reset=Reset layout
gridstack.status=Layout changed {0} widgets
gridstack.statusInitial=Layout unchanged
gridstack.widget=Widget {0}
gridstack.widgetText=Freely placeable card.
gridstack.dragHandle=Move
gridstack.close=Close
chart.revenueSeries=Revenue 2026
chart.pointClick=Series {0}, point {1}
@@ -24,15 +24,15 @@ card.registration=Registro
card.employees=Empleados
card.gridstackHint=Cómo funciona
gridstack.hint=Mueve las tarjetas con el asa de la esquina superior derecha y cambia su tamaño desde la esquina inferior 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. El diseño se guarda en el navegador y se restaura en la próxima visita.
gridstack.addWidget=Añadir widget
gridstack.removeWidget=Quitar el último
gridstack.reset=Restablecer diseño
gridstack.status=Diseño modificado {0} widgets
gridstack.statusInitial=Diseño sin cambios
gridstack.widget=Widget {0}
gridstack.widgetText=Tarjeta de colocación libre.
gridstack.dragHandle=Mover
gridstack.close=Cerrar
chart.revenueSeries=Ingresos 2026
chart.pointClick=Serie {0}, punto {1}