From 7a4bc7279903227cdbd9d3453d372ef9b927bb42 Mon Sep 17 00:00:00 2001 From: Pit Friedrich Date: Wed, 29 Jul 2026 07:52:36 +0200 Subject: [PATCH] fix: responsive grid breakpoints for small screens (#41) Add a columnOpts breakpoint (768px -> 1 column) so widgets stack full-width on mobile instead of staying at 12 columns. Persisting to localStorage is skipped while gridstack's own responsive collapse is active, so narrowing the window can no longer overwrite the saved desktop layout; drag/resize is disabled below the same breakpoint to avoid touch misfires. --- src/main/frontend/components/grid-stack.ts | 32 +++++ .../example/components/GridStackLayout.java | 24 ++++ .../java/com/example/views/DashboardView.java | 6 + .../e2e/ResponsiveGridPlaywrightTest.java | 116 ++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 src/test/java/com/example/e2e/ResponsiveGridPlaywrightTest.java diff --git a/src/main/frontend/components/grid-stack.ts b/src/main/frontend/components/grid-stack.ts index 554d29f..9890713 100644 --- a/src/main/frontend/components/grid-stack.ts +++ b/src/main/frontend/components/grid-stack.ts @@ -11,6 +11,13 @@ class GridStackLayout extends HTMLElement { private observer?: MutationObserver; private storageKey: string | null = null; private persistTimer?: ReturnType; + /** The column count configured server-side (before any responsive + * collapse) — {@link persist} compares the live column count against + * this to tell a real layout change from gridstack's own breakpoint + * collapse. */ + private fullColumn = 12; + private mobileQuery?: MediaQueryList; + private mobileQueryHandler?: (e: MediaQueryListEvent) => void; connectedCallback() { this.observer = new MutationObserver((mutations) => this.onMutation(mutations)); @@ -20,6 +27,11 @@ class GridStackLayout extends HTMLElement { disconnectedCallback() { this.observer?.disconnect(); this.observer = undefined; + if (this.mobileQuery && this.mobileQueryHandler) { + this.mobileQuery.removeEventListener('change', this.mobileQueryHandler); + } + this.mobileQuery = undefined; + this.mobileQueryHandler = undefined; // Write out a still-pending debounced save instead of dropping it: // a drag/resize followed straight away by navigating to another route // detaches this element inside the debounce window, which used to lose @@ -39,6 +51,7 @@ class GridStackLayout extends HTMLElement { this.classList.add('grid-stack'); const options = JSON.parse(optionsJson); + this.fullColumn = options.column ?? this.fullColumn; const grid = GridStack.init(options, this); if (!grid) return; this.grid = grid; @@ -48,6 +61,19 @@ class GridStackLayout extends HTMLElement { grid.on('change added removed', () => this.schedulePersist()); grid.on('resizestop', () => window.dispatchEvent(new Event('resize'))); + + // Below the responsive breakpoint, disable drag/resize: rearranging a + // stacked mobile layout by touch is mostly misfires, and it's the + // desktop layout — not the stacked one — that's worth protecting from + // an accidental drag. Mirrors the same width gridstack's own + // columnOpts.breakpoints collapses at, so both switch together. + const breakpointWidth: number | undefined = options.columnOpts?.breakpoints?.[0]?.w; + if (breakpointWidth) { + this.mobileQuery = window.matchMedia(`(max-width: ${breakpointWidth}px)`); + this.mobileQueryHandler = (e) => this.grid?.setStatic(e.matches); + this.mobileQuery.addEventListener('change', this.mobileQueryHandler); + this.grid.setStatic(this.mobileQuery.matches); + } } /** Drops any saved layout for this grid and re-applies the positions @@ -119,6 +145,12 @@ class GridStackLayout extends HTMLElement { private persist(notifyServer = true) { if (!this.grid) return; + // gridstack's responsive columnOpts collapse fires the same 'change' + // event a real drag/resize would, with save() reporting whatever + // shape the engine forced items into at 1 column. That shape is not + // something the user chose — persisting it would silently overwrite + // the desktop layout the next time the window narrows. + if (this.grid.getColumn() !== this.fullColumn) return; const nodes = this.grid.save(false) as GridStackNode[]; if (this.storageKey) { localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes)); diff --git a/src/main/java/com/example/components/GridStackLayout.java b/src/main/java/com/example/components/GridStackLayout.java index d66deee..b4bbf42 100644 --- a/src/main/java/com/example/components/GridStackLayout.java +++ b/src/main/java/com/example/components/GridStackLayout.java @@ -102,6 +102,30 @@ public class GridStackLayout extends Component implements HasSize, HasStyle { return this; } + /** + * Below {@code maxWidthPx} (measured against the browser window, not just + * this element's own width), gridstack collapses to {@code columns} + * columns — pass 1 to stack every item full-width, which is what makes a + * chart legible on a phone. Drag/resize is disabled below the same + * threshold (see {@code grid-stack.ts}): rearranging a stacked mobile + * layout by touch is mostly misfires, and gridstack still restores the + * pre-collapse positions once the window widens back past the threshold — + * that restore is the engine's own column-change cache, not something this + * class drives. Persisting to {@code localStorage} (see + * {@link #setStorageKey(String)}) is suppressed while collapsed, so a + * narrowed window can never overwrite the saved desktop layout. + */ + public GridStackLayout setResponsiveBreakpoint(int maxWidthPx, int columns) { + Map breakpoint = new LinkedHashMap<>(); + breakpoint.put("w", maxWidthPx); + breakpoint.put("c", columns); + Map columnOpts = new LinkedHashMap<>(); + columnOpts.put("breakpointForWindow", true); + columnOpts.put("breakpoints", List.of(breakpoint)); + options.put("columnOpts", columnOpts); + return this; + } + /** * Enables browser-localStorage persistence of the layout under the given * key (shared across sessions/tabs on the same origin — pick something diff --git a/src/main/java/com/example/views/DashboardView.java b/src/main/java/com/example/views/DashboardView.java index 0dbf430..83d9e3e 100644 --- a/src/main/java/com/example/views/DashboardView.java +++ b/src/main/java/com/example/views/DashboardView.java @@ -59,6 +59,10 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { private static final String STORAGE_KEY = "dashboard"; + /** Matches gridstack's {@code columnOpts} breakpoint below which the grid + * stacks to a single column (see {@link GridStackLayout#setResponsiveBreakpoint}). */ + private static final int MOBILE_BREAKPOINT_PX = 768; + /** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */ private static final int KPI_WIDTH = 3; @@ -83,6 +87,8 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { grid.setWidthFull(); grid.setStorageKey(STORAGE_KEY); + // Below phone/small-tablet width, stack every widget full-width. + grid.setResponsiveBreakpoint(MOBILE_BREAKPOINT_PX, 1); grid.addLayoutChangeListener(e -> status.setText( getTranslation("gridstack.status", e.getPositions().size()))); diff --git a/src/test/java/com/example/e2e/ResponsiveGridPlaywrightTest.java b/src/test/java/com/example/e2e/ResponsiveGridPlaywrightTest.java new file mode 100644 index 0000000..6d4410b --- /dev/null +++ b/src/test/java/com/example/e2e/ResponsiveGridPlaywrightTest.java @@ -0,0 +1,116 @@ +package com.example.e2e; + +import com.microsoft.playwright.Locator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end coverage for the {@code columnOpts} breakpoint added to + * {@code GridStackLayout} (see {@code DashboardView#MOBILE_BREAKPOINT_PX}): + * gridstack's own column-change caching restores the desktop layout, but only + * a real browser proves the resize actually happens and that the collapsed, + * single-column shape never gets written into the persisted layout. + */ +class ResponsiveGridPlaywrightTest extends PlaywrightTestBase { + + private static final int DESKTOP_WIDTH = 1280; + private static final int DESKTOP_HEIGHT = 900; + private static final int MOBILE_WIDTH = 375; + private static final int MOBILE_HEIGHT = 720; + + @BeforeEach + void openDashboardAtDesktopWidth() { + page.setViewportSize(DESKTOP_WIDTH, DESKTOP_HEIGHT); + navigate(""); + assertThat(widget()).isVisible(); + page.waitForCondition(() -> columns() == 12); + // Let any debounced persist from the initial makeWidget() calls settle + // before a test starts measuring, so it isn't racing that write. + page.waitForTimeout(300); + } + + @Test + void mobileViewport_stacksWidgetsFullWidthAndDisablesDragging() { + double gridWidthDesktop = gridWidth(); + double itemWidthDesktop = itemWidth(); + assertTrue(itemWidthDesktop < gridWidthDesktop - 1, + "at desktop width the widget must not already span the whole grid"); + + page.setViewportSize(MOBILE_WIDTH, MOBILE_HEIGHT); + page.waitForCondition(() -> columns() == 1); + + double gridWidthMobile = gridWidth(); + double itemWidthMobile = itemWidth(); + assertEquals(gridWidthMobile, itemWidthMobile, 1.0, + "below the breakpoint every widget must span the full grid width"); + assertTrue(isStatic(), "dragging/resizing must be disabled below the breakpoint"); + } + + @Test + void collapsingToMobile_doesNotOverwriteThePersistedDesktopLayout() { + String before = slot(widget()); + String storedBefore = storedLayout(); + + page.setViewportSize(MOBILE_WIDTH, MOBILE_HEIGHT); + page.waitForCondition(() -> columns() == 1); + // give the (debounced) persist path a chance to run, if it were going to + page.waitForTimeout(300); + assertEquals(storedBefore, storedLayout(), + "collapsing to one column must not touch the persisted desktop layout"); + + page.setViewportSize(DESKTOP_WIDTH, DESKTOP_HEIGHT); + page.waitForCondition(() -> columns() == 12); + + assertEquals(before, slot(widget()), "the original desktop layout must be restored"); + assertFalse(isStatic(), "dragging/resizing must be re-enabled back above the breakpoint"); + } + + private Locator widget() { + return page.locator(".grid-stack-item[gs-id='revenue-trend']"); + } + + private int columns() { + Object value = page.locator(".grid-stack").evaluate( + "el => el.style.getPropertyValue('--gs-columns')"); + return Integer.parseInt(String.valueOf(value)); + } + + private boolean isStatic() { + return (boolean) page.locator(".grid-stack").evaluate( + "el => el.classList.contains('grid-stack-static')"); + } + + private double gridWidth() { + return ((Number) page.locator(".grid-stack").evaluate( + "el => el.getBoundingClientRect().width")).doubleValue(); + } + + private double itemWidth() { + return ((Number) widget().evaluate( + "el => el.getBoundingClientRect().width")).doubleValue(); + } + + /** The value {@code GridStackLayout} keeps in {@code localStorage} for this + * view, unaffected by the responsive collapse. */ + private String storedLayout() { + Object value = page.evaluate("() => localStorage.getItem('gridstack:dashboard')"); + return value == null ? null : String.valueOf(value); + } + + /** The item's box measured against the grid, not the viewport — same as + * {@code WidgetActionMenuPlaywrightTest#slot}. */ + private String slot(Locator item) { + return (String) item.evaluate(""" + el => { + const grid = el.closest('.grid-stack').getBoundingClientRect(); + const box = el.getBoundingClientRect(); + return [box.left - grid.left, box.top - grid.top, box.width, box.height] + .map(Math.round).join(','); + }"""); + } +} -- 2.52.0