From 7d23eef81b2707c6592a716812d819c7b755fe5a Mon Sep 17 00:00:00 2001 From: Pit Friedrich Date: Sun, 26 Jul 2026 20:17:05 +0200 Subject: [PATCH] fix: make dashboard widgets closable (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ue9ZtWUBQF4SuSHpzZ3zwq --- src/main/java/com/example/components/Fa.java | 3 +- .../com/example/components/GridStackItem.java | 80 ++++++++++++++++++- .../java/com/example/views/GridStackView.java | 31 +++---- .../resources/META-INF/resources/styles.css | 40 +++++++++- .../vaadin-i18n/translations.properties | 4 +- .../vaadin-i18n/translations_en.properties | 4 +- .../vaadin-i18n/translations_es.properties | 4 +- .../com/example/views/GridStackViewTest.java | 56 +++++++++++-- 8 files changed, 185 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/example/components/Fa.java b/src/main/java/com/example/components/Fa.java index c60d053..665c2b2 100644 --- a/src/main/java/com/example/components/Fa.java +++ b/src/main/java/com/example/components/Fa.java @@ -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; diff --git a/src/main/java/com/example/components/GridStackItem.java b/src/main/java/com/example/components/GridStackItem.java index 953fda0..794b4a5 100644 --- a/src/main/java/com/example/components/GridStackItem.java +++ b/src/main/java/com/example/components/GridStackItem.java @@ -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; *

* 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 listener) { + return addListener(CloseEvent.class, listener); + } + + /** Fired after the item has been removed from its {@link GridStackLayout}. */ + public static class CloseEvent extends ComponentEvent { + CloseEvent(GridStackItem source, boolean fromClient) { + super(source, fromClient); + } } public void add(Component... components) { diff --git a/src/main/java/com/example/views/GridStackView.java b/src/main/java/com/example/views/GridStackView.java index 86193b6..80c65b3 100644 --- a/src/main/java/com/example/views/GridStackView.java +++ b/src/main/java/com/example/views/GridStackView.java @@ -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 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() { diff --git a/src/main/resources/META-INF/resources/styles.css b/src/main/resources/META-INF/resources/styles.css index 39e036d..d872946 100644 --- a/src/main/resources/META-INF/resources/styles.css +++ b/src/main/resources/META-INF/resources/styles.css @@ -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; } } diff --git a/src/main/resources/vaadin-i18n/translations.properties b/src/main/resources/vaadin-i18n/translations.properties index 91496e3..5e01b51 100644 --- a/src/main/resources/vaadin-i18n/translations.properties +++ b/src/main/resources/vaadin-i18n/translations.properties @@ -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} diff --git a/src/main/resources/vaadin-i18n/translations_en.properties b/src/main/resources/vaadin-i18n/translations_en.properties index b54b698..192a9bc 100644 --- a/src/main/resources/vaadin-i18n/translations_en.properties +++ b/src/main/resources/vaadin-i18n/translations_en.properties @@ -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} diff --git a/src/main/resources/vaadin-i18n/translations_es.properties b/src/main/resources/vaadin-i18n/translations_es.properties index 7614bdb..f3cdde7 100644 --- a/src/main/resources/vaadin-i18n/translations_es.properties +++ b/src/main/resources/vaadin-i18n/translations_es.properties @@ -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} diff --git a/src/test/java/com/example/views/GridStackViewTest.java b/src/test/java/com/example/views/GridStackViewTest.java index 8041b7e..04daf7c 100644 --- a/src/test/java/com/example/views/GridStackViewTest.java +++ b/src/test/java/com/example/views/GridStackViewTest.java @@ -5,13 +5,18 @@ import com.example.components.GridStackItem; import com.example.components.GridStackLayout; import com.vaadin.browserless.SpringBrowserlessTest; import com.vaadin.browserless.ViewPackages; +import com.vaadin.browserless.internal.ElementUtilsKt; import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.dom.DomEvent; +import com.vaadin.flow.internal.JacksonUtils; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -32,7 +37,7 @@ class GridStackViewTest extends SpringBrowserlessTest { } @Test - void addAndRemoveWidget_changesItemCount() { + void addAndCloseWidget_changesItemCount() { navigate(GridStackView.class); GridStackLayout grid = $view(GridStackLayout.class).first(); @@ -41,18 +46,59 @@ class GridStackViewTest extends SpringBrowserlessTest { button("gridstack.addWidget").click(); assertEquals(initial + 1, grid.getLayout().size()); - button("gridstack.removeWidget").click(); + clickCloseButton($view(GridStackItem.class).last()); assertEquals(initial, grid.getLayout().size()); } @Test - void removeWidget_onDefaultLayout_keepsStaticWidgets() { + void closeButton_removesTheClickedWidgetOnly() { navigate(GridStackView.class); GridStackLayout grid = $view(GridStackLayout.class).first(); - button("gridstack.removeWidget").click(); + GridStackItem first = $view(GridStackItem.class).first(); + String closedId = first.getItemId(); - assertEquals(4, grid.getLayout().size(), "static widgets must not be removable"); + clickCloseButton(first); + + List layout = grid.getLayout(); + assertEquals(3, layout.size()); + assertFalse(layout.stream().anyMatch(p -> closedId.equals(p.id())), + "the closed widget must be gone from the layout"); + } + + @Test + void closeListener_firesOnceItemIsDetached() { + navigate(GridStackView.class); + + GridStackLayout grid = $view(GridStackLayout.class).first(); + GridStackItem item = $view(GridStackItem.class).first(); + int[] fired = {0}; + item.addCloseListener(e -> fired[0]++); + + item.close(); + + assertEquals(1, fired[0]); + assertEquals(3, grid.getLayout().size()); + } + + @Test + void nonClosableItem_hasNoCloseButton() { + navigate(GridStackView.class); + + GridStackItem item = $view(GridStackItem.class).first(); + assertTrue(item.isClosable(), "items are closable by default"); + + item.setClosable(false); + assertFalse(item.isClosable()); + assertFalse(item.getChildren().anyMatch(child -> child == item.getCloseButton())); + } + + /** Fires the DOM click the close button listens for, rather than calling + * {@code close()} directly, so the button's own wiring is covered too. */ + private void clickCloseButton(GridStackItem item) { + Div closeButton = item.getCloseButton(); + ElementUtilsKt._fireDomEvent(closeButton.getElement(), + new DomEvent(closeButton.getElement(), "click", JacksonUtils.createObjectNode())); } private Button button(String translationKey) { -- 2.52.0