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) {