diff --git a/src/main/java/com/example/components/Fa.java b/src/main/java/com/example/components/Fa.java
index 33c9ed3..7e5c701 100644
--- a/src/main/java/com/example/components/Fa.java
+++ b/src/main/java/com/example/components/Fa.java
@@ -20,7 +20,11 @@ public enum Fa {
FORM("fa-solid", "fa-rectangle-list"),
TABLE("fa-solid", "fa-table"),
GLOBE("fa-solid", "fa-globe"),
- SEARCH("fa-solid", "fa-magnifying-glass");
+ SEARCH("fa-solid", "fa-magnifying-glass"),
+ GRID("fa-solid", "fa-table-cells-large"),
+ ADD("fa-solid", "fa-plus"),
+ REMOVE("fa-solid", "fa-trash"),
+ RESET("fa-solid", "fa-arrow-rotate-left");
private final String[] classes;
diff --git a/src/main/java/com/example/components/GridStackLayout.java b/src/main/java/com/example/components/GridStackLayout.java
index e3c9984..190c42a 100644
--- a/src/main/java/com/example/components/GridStackLayout.java
+++ b/src/main/java/com/example/components/GridStackLayout.java
@@ -106,10 +106,17 @@ public class GridStackLayout extends Component implements HasSize, HasStyle {
}
}
- /** Convenience overload: wraps a plain component in a default-sized {@link GridStackItem}. */
+ /** Convenience overload: wraps a plain component in a default-sized
+ * {@link GridStackItem} (an item passed in directly is added as is).
+ *
+ * Appends through the element API rather than calling {@code add(item)}:
+ * a single-argument call resolves to this overload, not the varargs one,
+ * so delegating would recurse into itself. */
public GridStackItem add(Component content) {
- GridStackItem item = new GridStackItem(content);
- add(item);
+ GridStackItem item = content instanceof GridStackItem existing
+ ? existing
+ : new GridStackItem(content);
+ getElement().appendChild(item.getElement());
return item;
}
diff --git a/src/main/java/com/example/views/GridStackView.java b/src/main/java/com/example/views/GridStackView.java
new file mode 100644
index 0000000..86193b6
--- /dev/null
+++ b/src/main/java/com/example/views/GridStackView.java
@@ -0,0 +1,145 @@
+package com.example.views;
+
+import com.example.components.BarChart;
+import com.example.components.Card;
+import com.example.components.Fa;
+import com.example.components.GridStackItem;
+import com.example.components.GridStackLayout;
+import com.example.components.LineChart;
+import com.example.components.PieChart;
+import com.example.components.ApexChart;
+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.html.Paragraph;
+import com.vaadin.flow.component.html.Span;
+import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
+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.
+ */
+@Route("gridstack")
+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;
+
+ public GridStackView() {
+ addClassName("dialect-content");
+
+ grid.setWidthFull();
+ grid.setStorageKey(STORAGE_KEY);
+ grid.addLayoutChangeListener(e -> status.setText(
+ getTranslation("gridstack.status", e.getPositions().size())));
+
+ grid.add(
+ new GridStackItem("revenue-trend", 0, 0, 6, 3,
+ new Card(getTranslation("card.revenueTrend"), lineChart())),
+ new GridStackItem("revenue-month", 6, 0, 6, 3,
+ new Card(getTranslation("card.revenueByMonth"), barChart())),
+ new GridStackItem("revenue-region", 0, 3, 5, 3,
+ new Card(getTranslation("card.revenueByRegion"), pieChart())),
+ new GridStackItem("hint", 5, 3, 7, 3,
+ new Card(getTranslation("card.gridstackHint"),
+ new Paragraph(getTranslation("gridstack.hint")))));
+
+ status.setText(getTranslation("gridstack.statusInitial"));
+ status.addClassName("dialect-muted");
+
+ add(toolbar(), grid);
+ }
+
+ private HorizontalLayout toolbar() {
+ Button add = new Button(getTranslation("gridstack.addWidget"), Fa.ADD.create(),
+ 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);
+ toolbar.setPadding(false);
+ toolbar.setWidthFull();
+ toolbar.setAlignItems(Alignment.CENTER);
+ return toolbar;
+ }
+
+ 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.
+ 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--;
+ }
+
+ private Component lineChart() {
+ LineChart chart = new LineChart();
+ chart.setData(getTranslation("chart.revenueSeries"),
+ List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months());
+ return sizeFull(chart);
+ }
+
+ private Component barChart() {
+ BarChart chart = new BarChart();
+ chart.setData(getTranslation("chart.revenueSeries"),
+ List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months());
+ return sizeFull(chart);
+ }
+
+ private Component pieChart() {
+ PieChart chart = new PieChart();
+ chart.setData(List.of(30.0, 40.0, 35.0, 50.0),
+ List.of(getTranslation("region.north"), getTranslation("region.south"),
+ getTranslation("region.east"), getTranslation("region.west")));
+ return sizeFull(chart);
+ }
+
+ /** Charts fill their grid item instead of using a fixed pixel height, so
+ * resizing a widget resizes the chart (grid-stack.ts fires a window
+ * resize on resizestop, which ApexCharts reflows on). */
+ private Component sizeFull(ApexChart chart) {
+ chart.setWidthFull();
+ chart.setHeight("100%");
+ return chart;
+ }
+
+ private List months() {
+ return List.of(
+ getTranslation("month.jan"), getTranslation("month.feb"),
+ getTranslation("month.mar"), getTranslation("month.apr"),
+ getTranslation("month.may"), getTranslation("month.jun"));
+ }
+
+ @Override
+ public String getPageTitle() {
+ return getTranslation("page.gridstack");
+ }
+}
diff --git a/src/main/java/com/example/views/MainLayout.java b/src/main/java/com/example/views/MainLayout.java
index 0ea2e4f..c9ccc7d 100644
--- a/src/main/java/com/example/views/MainLayout.java
+++ b/src/main/java/com/example/views/MainLayout.java
@@ -49,6 +49,8 @@ public class MainLayout extends AppLayout {
Fa.FORM.create()));
nav.addItem(new SideNavItem(getTranslation("nav.table"), TableView.class,
Fa.TABLE.create()));
+ nav.addItem(new SideNavItem(getTranslation("nav.gridstack"), GridStackView.class,
+ Fa.GRID.create()));
addToDrawer(nav);
}
diff --git a/src/main/resources/META-INF/resources/styles.css b/src/main/resources/META-INF/resources/styles.css
index 2feed68..eb1a3bb 100644
--- a/src/main/resources/META-INF/resources/styles.css
+++ b/src/main/resources/META-INF/resources/styles.css
@@ -123,6 +123,13 @@ vaadin-app-layout::part(content) {
margin: 0 0 12px 0;
}
+/* Secondary text (e.g. the layout status line in GridStackView). */
+.dialect-muted {
+ color: var(--dialect-ink);
+ opacity: 0.65;
+ font-size: 0.9rem;
+}
+
/* GridStackLayout (components/GridStackLayout.java): extend the same alias
layer used by .dialect-card rather than hardcoding new colors. */
.grid-stack-item-content {
diff --git a/src/main/resources/vaadin-i18n/translations.properties b/src/main/resources/vaadin-i18n/translations.properties
index 89bb3dd..9d04a42 100644
--- a/src/main/resources/vaadin-i18n/translations.properties
+++ b/src/main/resources/vaadin-i18n/translations.properties
@@ -10,16 +10,28 @@ lang.es=Español
nav.dashboard=Dashboard
nav.form=Formular
nav.table=Tabelle
+nav.gridstack=Raster
page.dashboard=Dashboard
page.form=Formular
page.table=Tabelle
+page.gridstack=Raster-Layout
card.revenueTrend=Umsatz-Entwicklung
card.revenueByMonth=Umsatz nach Monat
card.revenueByRegion=Umsatz nach Region
card.registration=Registrierung
card.employees=Mitarbeiter
+card.gridstackHint=Bedienung
+
+gridstack.hint=Karten am Titel greifen und verschieben, an der unteren rechten Ecke die Größe ändern. 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.
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 5142573..1439663 100644
--- a/src/main/resources/vaadin-i18n/translations_en.properties
+++ b/src/main/resources/vaadin-i18n/translations_en.properties
@@ -10,16 +10,28 @@ lang.es=Español
nav.dashboard=Dashboard
nav.form=Form
nav.table=Table
+nav.gridstack=Grid
page.dashboard=Dashboard
page.form=Form
page.table=Table
+page.gridstack=Grid layout
card.revenueTrend=Revenue Trend
card.revenueByMonth=Revenue by Month
card.revenueByRegion=Revenue by Region
card.registration=Registration
card.employees=Employees
+card.gridstackHint=How it works
+
+gridstack.hint=Drag cards by their header to move them, resize them from the bottom 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.
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 5f034cb..2071ab7 100644
--- a/src/main/resources/vaadin-i18n/translations_es.properties
+++ b/src/main/resources/vaadin-i18n/translations_es.properties
@@ -10,16 +10,28 @@ lang.es=Español
nav.dashboard=Panel
nav.form=Formulario
nav.table=Tabla
+nav.gridstack=Cuadrícula
page.dashboard=Panel
page.form=Formulario
page.table=Tabla
+page.gridstack=Diseño de cuadrícula
card.revenueTrend=Evolución de ingresos
card.revenueByMonth=Ingresos por mes
card.revenueByRegion=Ingresos por región
card.registration=Registro
card.employees=Empleados
+card.gridstackHint=Cómo funciona
+
+gridstack.hint=Arrastra las tarjetas por su título para moverlas 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.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.
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
new file mode 100644
index 0000000..8041b7e
--- /dev/null
+++ b/src/test/java/com/example/views/GridStackViewTest.java
@@ -0,0 +1,63 @@
+package com.example.views;
+
+import com.example.Application;
+import com.example.components.GridStackItem;
+import com.example.components.GridStackLayout;
+import com.vaadin.browserless.SpringBrowserlessTest;
+import com.vaadin.browserless.ViewPackages;
+import com.vaadin.flow.component.button.Button;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest(classes = Application.class)
+@ViewPackages(classes = GridStackView.class)
+class GridStackViewTest extends SpringBrowserlessTest {
+
+ @Test
+ void view_rendersGridWithDefaultWidgets() {
+ navigate(GridStackView.class);
+
+ GridStackLayout grid = $view(GridStackLayout.class).first();
+ assertNotNull(grid);
+
+ List layout = grid.getLayout();
+ assertEquals(4, layout.size(), "expected the four default widgets");
+ assertTrue(layout.stream().allMatch(p -> p.w() > 0 && p.h() > 0));
+ }
+
+ @Test
+ void addAndRemoveWidget_changesItemCount() {
+ navigate(GridStackView.class);
+
+ GridStackLayout grid = $view(GridStackLayout.class).first();
+ int initial = grid.getLayout().size();
+
+ button("gridstack.addWidget").click();
+ assertEquals(initial + 1, grid.getLayout().size());
+
+ button("gridstack.removeWidget").click();
+ assertEquals(initial, grid.getLayout().size());
+ }
+
+ @Test
+ void removeWidget_onDefaultLayout_keepsStaticWidgets() {
+ navigate(GridStackView.class);
+
+ GridStackLayout grid = $view(GridStackLayout.class).first();
+ button("gridstack.removeWidget").click();
+
+ assertEquals(4, grid.getLayout().size(), "static widgets must not be removable");
+ }
+
+ private Button button(String translationKey) {
+ String caption = getCurrentView().getElement().getComponent()
+ .orElseThrow().getTranslation(translationKey);
+ return $view(Button.class).withText(caption).first();
+ }
+}